git.iko.soy/opencode-android
on master help sign in
Add syntax highlighting to chat code blocks, honor reduced-motion throughout the chat UI (rows, banners, FABs, reasoning/todo expand, image zoom), fix the labelMedium monospace leak and the 20dp failed-message dismiss touch target, apply the chat text scale to the file viewer, add copy-path to file rows and the viewer overflow, give session cards a monogram avatar plus shared/reverted badges, and advance the two-pane detail pane to a neighbor instead of blanking when the open session is removed
930859eiko
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+7−3
@@ -44,6 +44,7 @@ import soy.iko.opencode.ui.search.GlobalSearchScreen
44import soy.iko.opencode.ui.components.LargeScreenNavRail
44import soy.iko.opencode.ui.components.LargeScreenNavRail
45import soy.iko.opencode.ui.components.LocalChatTextScale
45import soy.iko.opencode.ui.components.LocalChatTextScale
46import soy.iko.opencode.ui.components.LocalCodeWrap
46import soy.iko.opencode.ui.components.LocalCodeWrap
47+import soy.iko.opencode.ui.components.LocalReducedMotion
47import soy.iko.opencode.ui.components.isReducedMotion
48import soy.iko.opencode.ui.components.isReducedMotion
48import soy.iko.opencode.ui.session.SessionListScreen
49import soy.iko.opencode.ui.session.SessionListScreen
49import soy.iko.opencode.ui.session.TwoPaneSessionChat
50import soy.iko.opencode.ui.session.TwoPaneSessionChat
@@ -153,13 +154,16 @@ fun OpencodeApp(container: AppContainer) {
153 )
154 )
154 }
155 }
155
156
157+ // Capture the reduced-motion preference once (in the composable scope) so the
158+ // non-composable NavHost transition lambdas can use it to skip animations, and provide
159+ // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
160+ // image zoom) honor the same accessibility preference.
161+ val reducedMotion = isReducedMotion()
156 CompositionLocalProvider(
162 CompositionLocalProvider(
157 LocalChatTextScale provides clampedChatTextScale,
163 LocalChatTextScale provides clampedChatTextScale,
158 LocalCodeWrap provides codeWrap,
164 LocalCodeWrap provides codeWrap,
165+ LocalReducedMotion provides reducedMotion,
159 ) {
166 ) {
160− // Capture the reduced-motion preference once (in the composable scope) so the
161− // non-composable NavHost transition lambdas can use it to skip animations.
162− val reducedMotion = isReducedMotion()
163 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
167 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
164 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
168 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
165 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
169 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+24−6
@@ -145,9 +145,11 @@ import soy.iko.opencode.R
145import soy.iko.opencode.ui.components.CommandPalette
145import soy.iko.opencode.ui.components.CommandPalette
146import soy.iko.opencode.ui.components.ConnectionBanner
146import soy.iko.opencode.ui.components.ConnectionBanner
147import soy.iko.opencode.ui.components.PaletteAction
147import soy.iko.opencode.ui.components.PaletteAction
148+import soy.iko.opencode.ui.components.LocalReducedMotion
148import soy.iko.opencode.ui.components.copyToClipboard
149import soy.iko.opencode.ui.components.copyToClipboard
149import soy.iko.opencode.ui.components.LocalRelativeTimeTick
150import soy.iko.opencode.ui.components.LocalRelativeTimeTick
150import soy.iko.opencode.ui.components.rememberRelativeTimeTick
151import soy.iko.opencode.ui.components.rememberRelativeTimeTick
152+import soy.iko.opencode.ui.components.rememberVisibilityTransitions
151import soy.iko.opencode.ui.components.showToast
153import soy.iko.opencode.ui.components.showToast
152import soy.iko.opencode.ui.components.toImageContext
154import soy.iko.opencode.ui.components.toImageContext
153import soy.iko.opencode.ui.vmFactory
155import soy.iko.opencode.ui.vmFactory
@@ -709,10 +711,19 @@ fun ChatScreen(
709 // Pinned above the composer so the agent's plan and progress stay visible
711 // Pinned above the composer so the agent's plan and progress stay visible
710 // while scrolled up mid-run; renders nothing when there's no plan.
712 // while scrolled up mid-run; renders nothing when there's no plan.
711 TodoPlanBar(todoPlan)
713 TodoPlanBar(todoPlan)
714+ val bannerMotion = rememberVisibilityTransitions()
712− AnimatedVisibility(visible = reverted) {
715+ AnimatedVisibility(
716+ visible = reverted,
717+ enter = bannerMotion.enter,
718+ exit = bannerMotion.exit,
719+ ) {
713 RevertBanner(onUndo = { vm.unrevert() })
720 RevertBanner(onUndo = { vm.unrevert() })
714 }
721 }
722+ AnimatedVisibility(
715− AnimatedVisibility(visible = queuedOffline.isNotEmpty()) {
723+ visible = queuedOffline.isNotEmpty(),
724+ enter = bannerMotion.enter,
725+ exit = bannerMotion.exit,
726+ ) {
716 OutboxBanner(
727 OutboxBanner(
717 count = queuedOffline.size,
728 count = queuedOffline.size,
718 sending = outboxSending,
729 sending = outboxSending,
@@ -1026,10 +1037,12 @@ fun ChatScreen(
1026 key = { it.key },
1037 key = { it.key },
1027 contentType = { it.contentType },
1038 contentType = { it.contentType },
1028 ) { item ->
1039 ) { item ->
1029− // Default placement animation so inserted/moved rows glide in. Kept to the
1040+ // Default placement animation so inserted/moved rows glide in. Skipped
1030− // default (no heavy fade spec) so a streaming message growing in place
1041+ // entirely under reduced motion so a streaming message growing in place
1031− // doesn't animate on every token.
1042+ // doesn't animate on every token (and respects the a11y preference).
1043+ Box(
1032− Box(Modifier.animateItem()) {
1044+ if (LocalReducedMotion.current) Modifier else Modifier.animateItem(),
1045+ ) {
1033 when (item) {
1046 when (item) {
1034 is MessageListItem.Separator -> DateSeparator(item.label)
1047 is MessageListItem.Separator -> DateSeparator(item.label)
1035 is MessageListItem.Message -> {
1048 is MessageListItem.Message -> {
@@ -1152,8 +1165,11 @@ fun ChatScreen(
1152 }
1165 }
1153 }
1166 }
1154 // Jump-to-latest affordance when the user has scrolled away during a stream.
1167 // Jump-to-latest affordance when the user has scrolled away during a stream.
1168+ val fabMotion = rememberVisibilityTransitions()
1155 AnimatedVisibility(
1169 AnimatedVisibility(
1156 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1170 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1171+ enter = fabMotion.enter,
1172+ exit = fabMotion.exit,
1157 modifier = Modifier.align(Alignment.BottomEnd),
1173 modifier = Modifier.align(Alignment.BottomEnd),
1158 ) {
1174 ) {
1159 // Badge the FAB when new content arrived while the user was scrolled up, so
1175 // Badge the FAB when new content arrived while the user was scrolled up, so
@@ -1192,6 +1208,8 @@ fun ChatScreen(
1192 }
1208 }
1193 AnimatedVisibility(
1209 AnimatedVisibility(
1194 visible = scrolledDown && listItems.size > 4,
1210 visible = scrolledDown && listItems.size > 4,
1211+ enter = fabMotion.enter,
1212+ exit = fabMotion.exit,
1195 modifier = Modifier.align(Alignment.BottomStart),
1213 modifier = Modifier.align(Alignment.BottomStart),
1196 ) {
1214 ) {
1197 SmallFloatingActionButton(
1215 SmallFloatingActionButton(
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+5−1
@@ -402,12 +402,16 @@ private fun UserBubble(
402 )
402 )
403 }
403 }
404 if (onDismiss != null) {
404 if (onDismiss != null) {
405+ // No size override on the IconButton: it keeps the default 48dp
406+ // touch target (only the inner Icon is shrunk to 14dp). An earlier
407+ // `.size(20.dp)` here clamped the target to 20dp — well under the
408+ // M3 minimum — which made the discard action hard to hit. Matches
409+ // the pattern used by every other inline IconButton in this file.
405 IconButton(
410 IconButton(
406 onClick = {
411 onClick = {
407 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
412 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
408 onDismiss()
413 onDismiss()
409 },
414 },
410− modifier = Modifier.size(20.dp),
411 ) {
415 ) {
412 Icon(
416 Icon(
413 Icons.Filled.Close,
417 Icons.Filled.Close,
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+7−1
@@ -78,6 +78,7 @@ import soy.iko.opencode.ui.components.RemoteImage
78import soy.iko.opencode.ui.components.copyToClipboard
78import soy.iko.opencode.ui.components.copyToClipboard
79import soy.iko.opencode.ui.components.isImage
79import soy.iko.opencode.ui.components.isImage
80import soy.iko.opencode.ui.components.looksLikeDiff
80import soy.iko.opencode.ui.components.looksLikeDiff
81+import soy.iko.opencode.ui.components.rememberVisibilityTransitions
81import soy.iko.opencode.ui.components.showCopyToast
82import soy.iko.opencode.ui.components.showCopyToast
82import soy.iko.opencode.R
83import soy.iko.opencode.R
83
84
@@ -214,7 +215,12 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
214 modifier = Modifier.padding(start = 6.dp),
215 modifier = Modifier.padding(start = 6.dp),
215 )
216 )
216 }
217 }
218+ val expandMotion = rememberVisibilityTransitions()
217− AnimatedVisibility(visible = expanded) {
219+ AnimatedVisibility(
220+ visible = expanded,
221+ enter = expandMotion.enter,
222+ exit = expandMotion.exit,
223+ ) {
218 Column {
224 Column {
219 // Wrap in SelectionContainer so the user can select a portion of the
225 // Wrap in SelectionContainer so the user can select a portion of the
220 // reasoning (e.g. a single step) instead of the all-or-nothing copy
226 // reasoning (e.g. a single step) instead of the all-or-nothing copy
app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt+7−1
@@ -44,6 +44,7 @@ import soy.iko.opencode.R
44import soy.iko.opencode.data.model.TodoItem
44import soy.iko.opencode.data.model.TodoItem
45import soy.iko.opencode.data.model.TodoStatus
45import soy.iko.opencode.data.model.TodoStatus
46import soy.iko.opencode.data.model.statusEnum
46import soy.iko.opencode.data.model.statusEnum
47+import soy.iko.opencode.ui.components.rememberVisibilityTransitions
47
48
48/** Renders a task plan as a checklist: a status icon per row plus its description, with
49/** Renders a task plan as a checklist: a status icon per row plus its description, with
49 * completed/cancelled items struck through and the in-progress item emphasized. */
50 * completed/cancelled items struck through and the in-progress item emphasized. */
@@ -151,7 +152,12 @@ fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
151 modifier = Modifier.size(20.dp),
152 modifier = Modifier.size(20.dp),
152 )
153 )
153 }
154 }
155+ val expandMotion = rememberVisibilityTransitions()
154− AnimatedVisibility(visible = expanded) {
156+ AnimatedVisibility(
157+ visible = expanded,
158+ enter = expandMotion.enter,
159+ exit = expandMotion.exit,
160+ ) {
155 TodoPlanChecklist(
161 TodoPlanChecklist(
156 todos,
162 todos,
157 modifier = Modifier
163 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+84−13
@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
2+
1package soy.iko.opencode.ui.components
3package soy.iko.opencode.ui.components
2
4
3import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.MaterialTheme
@@ -27,27 +29,41 @@ import androidx.compose.ui.text.withStyle
27
29
28/** Language families recognized by [highlightLine]. The set of keywords is shared
30/** Language families recognized by [highlightLine]. The set of keywords is shared
29 * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
31 * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
30− * common keywords overlap heavily; python has its own set; shell has its own. */
32+ * common keywords overlap heavily; python, ruby, shell, json each have their own. */
31internal enum class Language {
33internal enum class Language {
32 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
34 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
33 PYTHON,
35 PYTHON,
34 SHELL, // sh, bash, zsh
36 SHELL, // sh, bash, zsh
35 MARKUP, // html, xml, svg
37 MARKUP, // html, xml, svg
38+ RUBY,
39+ JSON,
36 NONE,
40 NONE,
37}
41}
38
42
39private fun languageFor(filename: String): Language {
43private fun languageFor(filename: String): Language {
40 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
44 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
41− return when (ext) {
45+ return languageForToken(ext)
46+}
47+
48+/**
49+ * Resolve a language from a markdown fence info string (e.g. "kotlin" from ```kotlin).
50+ * Accepts both full language names and file extensions so a fence tagged either way
51+ * (` ```py ` or ` ```python `) highlights. Lowercased by the caller via [languageForToken].
52+ */
53+internal fun languageForTag(tag: String): Language = languageForToken(tag.trim().lowercase())
54+
55+private fun languageForToken(token: String): Language = when (token) {
42− "kt", "kts", "java", "js", "mjs", "cjs", "ts", "tsx",
56+ "kt", "kotlin", "kts", "ktm", "java", "js", "javascript", "mjs", "cjs",
57+ "ts", "typescript", "tsx", "jsx",
43− "swift", "rs", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh",
58+ "swift", "rs", "rust", "go", "golang", "c", "h", "cpp", "c++", "cc", "cxx", "hpp", "hh",
44− "cs", "scala", "groovy", "dart", "gradle",
59+ "cs", "csharp", "c#", "scala", "groovy", "gradle", "dart",
45− -> Language.C_FAMILY
60+ -> Language.C_FAMILY
61+ "py", "python", "pyw" -> Language.PYTHON
46− "py", "pyw", "rb" -> Language.PYTHON
62+ "rb", "ruby" -> Language.RUBY
47− "sh", "bash", "zsh", "fish" -> Language.SHELL
63+ "sh", "bash", "zsh", "fish", "shell", "shellscript", "console" -> Language.SHELL
48− "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
64+ "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
65+ "json", "json5", "jsonc" -> Language.JSON
49− else -> Language.NONE
66+ else -> Language.NONE
50− }
51}
67}
52
68
53private val cFamilyKeywords = setOf(
69private val cFamilyKeywords = setOf(
@@ -76,6 +92,15 @@ private val shellKeywords = setOf(
76 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
92 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
77)
93)
78
94
95+private val rubyKeywords = setOf(
96+ "BEGIN", "END", "alias", "and", "begin", "break", "case", "class", "def", "defined?",
97+ "do", "else", "elsif", "end", "ensure", "false", "for", "if", "in", "module", "next",
98+ "nil", "not", "or", "redo", "rescue", "retry", "return", "self", "super", "then",
99+ "true", "undef", "unless", "until", "when", "while", "yield", "__FILE__", "__LINE__",
100+)
101+
102+private val jsonKeywords = setOf("true", "false", "null")
103+
79/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
104/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
80 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
105 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81data class HighlightPalette(
106data class HighlightPalette(
@@ -136,6 +161,50 @@ fun syntaxFor(filename: String): FileSyntax {
136 return FileSyntax(lang, keywordsFor(lang))
161 return FileSyntax(lang, keywordsFor(lang))
137}
162}
138
163
164+/**
165+ * Resolve the [FileSyntax] from a markdown fence language tag (e.g. "kotlin", "py",
166+ * "json"). Used by the chat code-block renderer, which only has the fence's info string
167+ * (not a filename). Returns a [FileSyntax] with [Language.NONE] for unknown tags so the
168+ * caller can short-circuit to plain text.
169+ */
170+fun syntaxForLanguageTag(tag: String): FileSyntax {
171+ val lang = languageForTag(tag)
172+ return FileSyntax(lang, keywordsFor(lang))
173+}
174+
175+/**
176+ * Highlight a whole (possibly multi-line) code snippet into a single [AnnotatedString].
177+ * Each line is highlighted independently via [highlightLine], because the per-line
178+ * scanner carries no state across lines (so multi-line block comments/triple-quoted
179+ * strings are not handled — an accepted trade-off documented on [highlightLine]).
180+ *
181+ * Used by the chat code-block renderer, which renders the whole block in one [Text].
182+ * The file viewer renders line-by-line instead and calls [highlightLine] directly.
183+ */
184+fun highlightCode(code: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
185+ if (syntax.lang == Language.NONE) return AnnotatedString(code)
186+ val builder = AnnotatedString.Builder(code.length + 16)
187+ val firstNewline = code.indexOf('\n')
188+ // Fast path for single-line code (common for short inline-ish fences): skip the
189+ // split + per-line append loop and the leading-newline bookkeeping entirely.
190+ if (firstNewline == -1) {
191+ builder.append(highlightLine(code, syntax, palette))
192+ return builder.toAnnotatedString()
193+ }
194+ var start = 0
195+ var first = true
196+ while (start <= code.length) {
197+ val end = code.indexOf('\n', start)
198+ val line = if (end == -1) code.substring(start) else code.substring(start, end)
199+ if (!first) builder.append("\n")
200+ builder.append(highlightLine(line, syntax, palette))
201+ first = false
202+ if (end == -1) break
203+ start = end + 1
204+ }
205+ return builder.toAnnotatedString()
206+}
207+
139/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
208/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
209fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141 val lang = syntax.lang
210 val lang = syntax.lang
@@ -211,6 +280,8 @@ private fun keywordsFor(lang: Language): Set<String> = when (lang) {
211 Language.C_FAMILY -> cFamilyKeywords
280 Language.C_FAMILY -> cFamilyKeywords
212 Language.PYTHON -> pythonKeywords
281 Language.PYTHON -> pythonKeywords
213 Language.SHELL -> shellKeywords
282 Language.SHELL -> shellKeywords
283+ Language.RUBY -> rubyKeywords
284+ Language.JSON -> jsonKeywords
214 Language.MARKUP -> emptySet()
285 Language.MARKUP -> emptySet()
215 Language.NONE -> emptySet()
286 Language.NONE -> emptySet()
216}
287}
@@ -219,9 +290,9 @@ private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): B
219 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
290 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
220 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
291 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
221 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
292 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
222− // `#` is a line comment only in Python and shell. It is NOT one in C-family
293+ // `#` is a line comment in Python, shell and Ruby. It is NOT one in C-family
223 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
294 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
224− (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
295+ (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL || lang == Language.RUBY))
225
296
226private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
297private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
227
298
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+12−1
@@ -38,6 +38,7 @@ import androidx.compose.ui.Alignment
38import androidx.compose.ui.Modifier
38import androidx.compose.ui.Modifier
39import androidx.compose.ui.draw.clip
39import androidx.compose.ui.draw.clip
40import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.platform.LocalContext
41+import androidx.compose.ui.text.AnnotatedString
41import androidx.compose.ui.text.TextStyle
42import androidx.compose.ui.text.TextStyle
42import androidx.compose.ui.text.font.FontFamily
43import androidx.compose.ui.text.font.FontFamily
43import androidx.compose.ui.unit.dp
44import androidx.compose.ui.unit.dp
@@ -188,6 +189,16 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
188 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
189 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
189 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
190 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
190 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
191 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
192+ // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
193+ // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
194+ // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
195+ // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
196+ // (NONE) so un-tagged fences render unchanged.
197+ val palette = rememberHighlightPalette()
198+ val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
199+ val highlighted = remember(code, syntax, palette) {
200+ if (syntax != null) highlightCode(code, syntax, palette) else AnnotatedString(code)
201+ }
191 // Default per-block wrap from the global preference; re-seed if the preference changes
202 // Default per-block wrap from the global preference; re-seed if the preference changes
192 // while this block is composed (rememberSaveable would over-persist across blocks).
203 // while this block is composed (rememberSaveable would over-persist across blocks).
193 val defaultWrap = LocalCodeWrap.current
204 val defaultWrap = LocalCodeWrap.current
@@ -250,7 +261,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
250 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
261 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
251 SelectionContainer {
262 SelectionContainer {
252 Text(
263 Text(
253− text = code,
264+ text = highlighted,
254 style = codeStyle,
265 style = codeStyle,
255 modifier = if (wrap) {
266 modifier = if (wrap) {
256 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
267 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+54
@@ -1,14 +1,28 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import android.provider.Settings
3import android.provider.Settings
4+import androidx.compose.animation.EnterTransition
5+import androidx.compose.animation.ExitTransition
6+import androidx.compose.animation.core.AnimationSpec
7+import androidx.compose.animation.core.snap
8+import androidx.compose.animation.core.tween
9+import androidx.compose.animation.expandVertically
10+import androidx.compose.animation.fadeIn
11+import androidx.compose.animation.fadeOut
12+import androidx.compose.animation.shrinkVertically
4import androidx.compose.runtime.Composable
13import androidx.compose.runtime.Composable
5import androidx.compose.runtime.remember
14import androidx.compose.runtime.remember
15+import androidx.compose.runtime.staticCompositionLocalOf
6import androidx.compose.ui.platform.LocalContext
16import androidx.compose.ui.platform.LocalContext
7
17
8/**
18/**
9 * Whether the user has reduced motion enabled at the system level (animator duration scale
19 * Whether the user has reduced motion enabled at the system level (animator duration scale
10 * set to 0 in Developer Options). When true, transitions should skip animations and content
20 * set to 0 in Developer Options). When true, transitions should skip animations and content
11 * swaps should be instant, respecting the user's accessibility preference.
21 * swaps should be instant, respecting the user's accessibility preference.
22+ *
23+ * Note: Compose's animation system does NOT honor [Settings.Global.ANIMATOR_DURATION_SCALE]
24+ * (that setting only gates the View-system ValueAnimator/ObjectAnimator), so each Compose
25+ * animation must opt in via [LocalReducedMotion].
12 */
26 */
13@Composable
27@Composable
14fun isReducedMotion(): Boolean {
28fun isReducedMotion(): Boolean {
@@ -23,3 +37,43 @@ fun isReducedMotion(): Boolean {
23 }.getOrDefault(false)
37 }.getOrDefault(false)
24 }
38 }
25}
39}
40+
41+/**
42+ * Whether reduced motion is in effect for this composition. Provided once near the app root
43+ * (see [soy.iko.opencode.ui.OpencodeApp]) from [isReducedMotion] so individual animation call
44+ * sites collapse their motion to instant swaps without each re-reading the system setting.
45+ * Defaults to false in previews/tests (no provider).
46+ */
47+val LocalReducedMotion = staticCompositionLocalOf { false }
48+
49+/**
50+ * Enter/exit pair for [androidx.compose.animation.AnimatedVisibility], pre-resolved to honor
51+ * [LocalReducedMotion]. Under reduced motion both are [EnterTransition.None] /
52+ * [ExitTransition.None] so content appears/disappears instantly; otherwise a standard
53+ * expand+fade / shrink+fade. Centralizes the gate so call sites stay one-liners.
54+ */
55+data class VisibilityTransitions(val enter: EnterTransition, val exit: ExitTransition)
56+
57+@Composable
58+fun rememberVisibilityTransitions(): VisibilityTransitions {
59+ val reduced = LocalReducedMotion.current
60+ return remember(reduced) {
61+ if (reduced) {
62+ VisibilityTransitions(EnterTransition.None, ExitTransition.None)
63+ } else {
64+ VisibilityTransitions(expandVertically() + fadeIn(), shrinkVertically() + fadeOut())
65+ }
66+ }
67+}
68+
69+/**
70+ * A [tween] [AnimationSpec] that collapses to an instant [snap] under [LocalReducedMotion],
71+ * for single-value animations (e.g. an image zoom) that want a single spec source.
72+ */
73+@Composable
74+fun <T> rememberMotionTween(durationMs: Int): AnimationSpec<T> {
75+ val reduced = LocalReducedMotion.current
76+ return remember(reduced, durationMs) {
77+ if (reduced) snap() else tween(durationMs)
78+ }
79+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−3
@@ -44,7 +44,6 @@ import coil.request.ImageRequest
44import kotlinx.coroutines.Dispatchers
44import kotlinx.coroutines.Dispatchers
45import kotlinx.coroutines.launch
45import kotlinx.coroutines.launch
46import androidx.compose.animation.core.Animatable
46import androidx.compose.animation.core.Animatable
47−import androidx.compose.animation.core.tween
48import kotlinx.coroutines.withContext
47import kotlinx.coroutines.withContext
49import androidx.compose.material.icons.Icons
48import androidx.compose.material.icons.Icons
50import androidx.compose.material.icons.filled.BrokenImage
49import androidx.compose.material.icons.filled.BrokenImage
@@ -252,6 +251,10 @@ private fun FullscreenImageViewer(
252 var offsetX by remember { mutableFloatStateOf(0f) }
251 var offsetX by remember { mutableFloatStateOf(0f) }
253 var offsetY by remember { mutableFloatStateOf(0f) }
252 var offsetY by remember { mutableFloatStateOf(0f) }
254 val scope = rememberCoroutineScope()
253 val scope = rememberCoroutineScope()
254+ // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
255+ // the composable body (not inside the pointerInput coroutine) since it reads the
256+ // composition's LocalReducedMotion.
257+ val zoomSpec = rememberMotionTween<Float>(IMAGE_ZOOM_ANIM_MS)
255 Dialog(
258 Dialog(
256 onDismissRequest = onDismiss,
259 onDismissRequest = onDismiss,
257 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
260 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -287,9 +290,9 @@ private fun FullscreenImageViewer(
287 if (scale.value > 1.001f) {
290 if (scale.value > 1.001f) {
288 offsetX = 0f
291 offsetX = 0f
289 offsetY = 0f
292 offsetY = 0f
290− scale.animateTo(1f, tween(IMAGE_ZOOM_ANIM_MS))
293+ scale.animateTo(1f, zoomSpec)
291 } else {
294 } else {
292− scale.animateTo(DOUBLE_TAP_ZOOM, tween(IMAGE_ZOOM_ANIM_MS))
295+ scale.animateTo(DOUBLE_TAP_ZOOM, zoomSpec)
293 }
296 }
294 }
297 }
295 },
298 },
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+34−1
@@ -1,6 +1,8 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3+import androidx.compose.foundation.ExperimentalFoundationApi
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Box
@@ -27,8 +29,11 @@ import androidx.compose.material.icons.filled.Home
27import androidx.compose.material.icons.filled.Image
29import androidx.compose.material.icons.filled.Image
28import androidx.compose.material.icons.filled.Code
30import androidx.compose.material.icons.filled.Code
29import androidx.compose.material.icons.filled.Article
31import androidx.compose.material.icons.filled.Article
32+import androidx.compose.material.icons.filled.Link
30import androidx.compose.material.icons.filled.Search
33import androidx.compose.material.icons.filled.Search
31import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.CircularProgressIndicator
35+import androidx.compose.material3.DropdownMenu
36+import androidx.compose.material3.DropdownMenuItem
32import androidx.compose.material3.ExperimentalMaterial3Api
37import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.FilterChip
38import androidx.compose.material3.FilterChip
34import androidx.compose.material3.HorizontalDivider
39import androidx.compose.material3.HorizontalDivider
@@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier
53import androidx.compose.ui.focus.FocusRequester
58import androidx.compose.ui.focus.FocusRequester
54import androidx.compose.ui.focus.focusRequester
59import androidx.compose.ui.focus.focusRequester
55import androidx.compose.ui.graphics.Color
60import androidx.compose.ui.graphics.Color
61+import androidx.compose.ui.platform.LocalContext
56import androidx.compose.ui.platform.LocalSoftwareKeyboardController
62import androidx.compose.ui.platform.LocalSoftwareKeyboardController
57import androidx.compose.ui.platform.testTag
63import androidx.compose.ui.platform.testTag
58import androidx.compose.ui.semantics.Role
64import androidx.compose.ui.semantics.Role
@@ -81,6 +87,7 @@ import soy.iko.opencode.R
81import soy.iko.opencode.ui.components.AppTopBar
87import soy.iko.opencode.ui.components.AppTopBar
82import soy.iko.opencode.ui.components.ConnectionBannerFor
88import soy.iko.opencode.ui.components.ConnectionBannerFor
83import soy.iko.opencode.ui.components.EmptyState
89import soy.iko.opencode.ui.components.EmptyState
90+import soy.iko.opencode.ui.components.copyToClipboard
84import soy.iko.opencode.ui.vmFactory
91import soy.iko.opencode.ui.vmFactory
85import soy.iko.opencode.util.runCatchingCancellable
92import soy.iko.opencode.util.runCatchingCancellable
86
93
@@ -274,6 +281,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
274
281
275@Composable
282@Composable
276private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
283private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
284+ val context = LocalContext.current
277 if (results.isEmpty()) {
285 if (results.isEmpty()) {
278 EmptyFileState(
286 EmptyFileState(
279 icon = Icons.Filled.Search,
287 icon = Icons.Filled.Search,
@@ -295,6 +303,9 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
295 label = name,
303 label = name,
296 sublabel = dir.takeIf { it.isNotEmpty() },
304 sublabel = dir.takeIf { it.isNotEmpty() },
297 onClick = { onOpenFile(path) },
305 onClick = { onOpenFile(path) },
306+ onCopyPath = {
307+ copyToClipboard(context, context.getString(R.string.clip_label_path), path)
308+ },
298 modifier = Modifier.animateItem(),
309 modifier = Modifier.animateItem(),
299 )
310 )
300 HorizontalDivider()
311 HorizontalDivider()
@@ -477,6 +488,7 @@ private fun DirectoryListing(
477 onUp: () -> Unit,
488 onUp: () -> Unit,
478 onOpenFile: (String) -> Unit,
489 onOpenFile: (String) -> Unit,
479) {
490) {
491+ val context = LocalContext.current
480 if (state.entries.isEmpty()) {
492 if (state.entries.isEmpty()) {
481 EmptyFileState(
493 EmptyFileState(
482 icon = Icons.Filled.Folder,
494 icon = Icons.Filled.Folder,
@@ -525,6 +537,9 @@ private fun DirectoryListing(
525 icon = node.isDirectory,
537 icon = node.isDirectory,
526 label = node.name,
538 label = node.name,
527 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
539 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
540+ onCopyPath = {
541+ copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
542+ },
528 status = state.statusMap[node.path],
543 status = state.statusMap[node.path],
529 modifier = Modifier.animateItem(),
544 modifier = Modifier.animateItem(),
530 )
545 )
@@ -540,6 +555,7 @@ private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector
540 EmptyState(icon = icon, title = message, modifier = modifier)
555 EmptyState(icon = icon, title = message, modifier = modifier)
541}
556}
542
557
558+@OptIn(ExperimentalFoundationApi::class)
543@Composable
559@Composable
544private fun FileRow(
560private fun FileRow(
545 icon: Boolean,
561 icon: Boolean,
@@ -547,8 +563,13 @@ private fun FileRow(
547 onClick: () -> Unit,
563 onClick: () -> Unit,
548 status: FileStatusEntry? = null,
564 status: FileStatusEntry? = null,
549 sublabel: String? = null,
565 sublabel: String? = null,
566+ onCopyPath: (() -> Unit)? = null,
550 modifier: Modifier = Modifier,
567 modifier: Modifier = Modifier,
551) {
568) {
569+ val copyPathLabel = stringResource(R.string.copy_path)
570+ // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
571+ // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
572+ var menu by remember { mutableStateOf(false) }
552 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
573 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
553 else if (icon) stringResource(R.string.folder, label)
574 else if (icon) stringResource(R.string.folder, label)
554 else stringResource(R.string.file_label, label)
575 else stringResource(R.string.file_label, label)
@@ -568,7 +589,12 @@ private fun FileRow(
568 modifier = modifier
589 modifier = modifier
569 .fillMaxWidth()
590 .fillMaxWidth()
570 .defaultMinSize(minHeight = 48.dp)
591 .defaultMinSize(minHeight = 48.dp)
592+ .combinedClickable(
571− .clickable(role = Role.Button, onClick = onClick)
593+ role = Role.Button,
594+ onClick = onClick,
595+ onLongClick = onCopyPath?.let { { menu = true } },
596+ onLongClickLabel = onCopyPath?.let { copyPathLabel },
597+ )
572 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
598 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
573 .padding(horizontal = 16.dp, vertical = 14.dp),
599 .padding(horizontal = 16.dp, vertical = 14.dp),
574 verticalAlignment = Alignment.CenterVertically,
600 verticalAlignment = Alignment.CenterVertically,
@@ -597,6 +623,13 @@ private fun FileRow(
597 }
623 }
598 }
624 }
599 if (status != null) StatusBadge(status)
625 if (status != null) StatusBadge(status)
626+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
627+ DropdownMenuItem(
628+ leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
629+ text = { Text(copyPathLabel) },
630+ onClick = { menu = false; onCopyPath?.invoke() },
631+ )
632+ }
600 }
633 }
601}
634}
602
635
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+72−50
@@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.ContentCopy
35import androidx.compose.material.icons.filled.ContentCopy
36import androidx.compose.material.icons.filled.KeyboardArrowDown
36import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowUp
37import androidx.compose.material.icons.filled.KeyboardArrowUp
38+import androidx.compose.material.icons.filled.Link
38import androidx.compose.material.icons.filled.MoreVert
39import androidx.compose.material.icons.filled.MoreVert
39import androidx.compose.material.icons.filled.Refresh
40import androidx.compose.material.icons.filled.Refresh
40import androidx.compose.material.icons.filled.Search
41import androidx.compose.material.icons.filled.Search
@@ -84,11 +85,13 @@ import soy.iko.opencode.di.AppContainer
84import soy.iko.opencode.R
85import soy.iko.opencode.R
85import soy.iko.opencode.ui.components.ConnectionBannerFor
86import soy.iko.opencode.ui.components.ConnectionBannerFor
86import soy.iko.opencode.ui.components.DiffView
87import soy.iko.opencode.ui.components.DiffView
88+import soy.iko.opencode.ui.components.LocalChatTextScale
87import soy.iko.opencode.ui.components.LocalCodeWrap
89import soy.iko.opencode.ui.components.LocalCodeWrap
88import soy.iko.opencode.ui.components.copyToClipboard
90import soy.iko.opencode.ui.components.copyToClipboard
89import soy.iko.opencode.ui.components.highlightLine
91import soy.iko.opencode.ui.components.highlightLine
90import soy.iko.opencode.ui.components.syntaxFor
92import soy.iko.opencode.ui.components.syntaxFor
91import soy.iko.opencode.ui.components.rememberHighlightPalette
93import soy.iko.opencode.ui.components.rememberHighlightPalette
94+import soy.iko.opencode.ui.components.scaledBy
92import soy.iko.opencode.ui.vmFactory
95import soy.iko.opencode.ui.vmFactory
93import soy.iko.opencode.util.runCatchingCancellable
96import soy.iko.opencode.util.runCatchingCancellable
94import kotlinx.coroutines.Dispatchers
97import kotlinx.coroutines.Dispatchers
@@ -201,6 +204,7 @@ fun FileViewScreen(
201 },
204 },
202 onToggleWrap = { wrap = !wrap },
205 onToggleWrap = { wrap = !wrap },
203 onCopy = { copyToClipboard(context, filename, rawText) },
206 onCopy = { copyToClipboard(context, filename, rawText) },
207+ onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
204 onShare = {
208 onShare = {
205 val send = Intent(Intent.ACTION_SEND).apply {
209 val send = Intent(Intent.ACTION_SEND).apply {
206 type = "text/plain"
210 type = "text/plain"
@@ -317,6 +321,7 @@ private fun FileViewTopBar(
317 onToggleFind: () -> Unit,
321 onToggleFind: () -> Unit,
318 onToggleWrap: () -> Unit,
322 onToggleWrap: () -> Unit,
319 onCopy: () -> Unit,
323 onCopy: () -> Unit,
324+ onCopyPath: () -> Unit,
320 onShare: () -> Unit,
325 onShare: () -> Unit,
321) {
326) {
322 TopAppBar(
327 TopAppBar(
@@ -377,6 +382,11 @@ private fun FileViewTopBar(
377 onClick = { onCopy(); menuExpanded = false },
382 onClick = { onCopy(); menuExpanded = false },
378 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
383 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
379 )
384 )
385+ DropdownMenuItem(
386+ text = { Text(stringResource(R.string.copy_path)) },
387+ onClick = { onCopyPath(); menuExpanded = false },
388+ leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
389+ )
380 DropdownMenuItem(
390 DropdownMenuItem(
381 text = { Text(stringResource(R.string.share)) },
391 text = { Text(stringResource(R.string.share)) },
382 onClick = { onShare(); menuExpanded = false },
392 onClick = { onShare(); menuExpanded = false },
@@ -637,13 +647,23 @@ private fun FileTextContent(
637 highlightLineIndex: Int?,
647 highlightLineIndex: Int?,
638 listState: LazyListState,
648 listState: LazyListState,
639) {
649) {
650+ // Honor the user's chat text-size preference (the same one the chat markdown honors) so
651+ // a user who bumped the scale for readability gets the same scale in the code viewer.
652+ // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
653+ // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
654+ // reaches both without per-Text plumbing.
655+ val scale = LocalChatTextScale.current
656+ val base = MaterialTheme.typography
657+ val scaled = remember(scale, base) { base.scaledBy(scale) }
640 // Gutter width scales with the digit count and the font scale so accessibility
658 // Gutter width scales with the digit count and the font scale so accessibility
641− // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
659+ // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
642− // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
660+ // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
643− // proportionally for larger font scales.
661+ // proportionally for larger font/app scales.
644 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
662 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
645− val gutterWidth = remember(lines.size, fontScale) {
663+ val gutterWidth = remember(lines.size, fontScale, scale) {
646− ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
664+ ((lines.size.toString().length.coerceAtLeast(3) * 10) *
665+ fontScale.coerceAtLeast(1f) *
666+ scale.coerceAtLeast(1f)).dp
647 }
667 }
648 val hScrollState = rememberScrollState()
668 val hScrollState = rememberScrollState()
649 val palette = rememberHighlightPalette()
669 val palette = rememberHighlightPalette()
@@ -651,51 +671,53 @@ private fun FileTextContent(
651 val syntax = remember(filename) { syntaxFor(filename) }
671 val syntax = remember(filename) { syntaxFor(filename) }
652 val q = findQuery.trim()
672 val q = findQuery.trim()
653 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
673 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
674+ MaterialTheme(typography = scaled) {
654− Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
675+ Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
655− if (truncated) {
676+ if (truncated) {
656− Text(
677+ Text(
657− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
678+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
658− style = MaterialTheme.typography.labelSmall,
679+ style = MaterialTheme.typography.labelSmall,
659− color = MaterialTheme.colorScheme.onSurfaceVariant,
680+ color = MaterialTheme.colorScheme.onSurfaceVariant,
660− modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
681+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
661− )
682+ )
662− }
683+ }
663− LazyColumn(
684+ LazyColumn(
664− state = listState,
685+ state = listState,
665− modifier = Modifier
686+ modifier = Modifier
666− .weight(1f)
687+ .weight(1f)
667− .fillMaxWidth()
688+ .fillMaxWidth()
668− .padding(8.dp),
689+ .padding(8.dp),
669− ) {
690+ ) {
670− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
691+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
671− val isMatch = q.isNotEmpty() && index in matchSet
692+ val isMatch = q.isNotEmpty() && index in matchSet
672− // The jump-to-line target keeps the same emphasis as a find match so the line
693+ // The jump-to-line target keeps the same emphasis as a find match so the line
673− // the viewer landed on is obvious until the user scrolls or starts a find.
694+ // the viewer landed on is obvious until the user scrolls or starts a find.
674− val isTarget = index == highlightLineIndex
695+ val isTarget = index == highlightLineIndex
675− Row(
696+ Row(
676− modifier = Modifier
697+ modifier = Modifier
677− .fillMaxWidth()
698+ .fillMaxWidth()
678− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
699+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
679− .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
700+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
680− ) {
701+ ) {
681− Text(
702+ Text(
682− "${index + 1}",
703+ "${index + 1}",
683− modifier = Modifier.width(gutterWidth),
704+ modifier = Modifier.width(gutterWidth),
684− style = MaterialTheme.typography.bodySmall,
705+ style = MaterialTheme.typography.bodySmall,
685− fontFamily = FontFamily.Monospace,
706+ fontFamily = FontFamily.Monospace,
686− color = MaterialTheme.colorScheme.onSurfaceVariant,
707+ color = MaterialTheme.colorScheme.onSurfaceVariant,
687− )
708+ )
688− // Heuristic syntax highlighting per line. Falls back to plain text for
709+ // Heuristic syntax highlighting per line. Falls back to plain text for
689− // unknown extensions, so non-code files render unchanged. Memoized so the
710+ // unknown extensions, so non-code files render unchanged. Memoized so the
690− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
711+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
691− // file, or palette actually changes — not on every recomposition (e.g. every
712+ // file, or palette actually changes — not on every recomposition (e.g. every
692− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
713+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
693− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
714+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
694− Text(
715+ Text(
695− highlighted,
716+ highlighted,
696− modifier = Modifier.padding(start = 8.dp),
717+ modifier = Modifier.padding(start = 8.dp),
697− style = MaterialTheme.typography.bodySmall,
718+ style = MaterialTheme.typography.bodySmall,
698− )
719+ )
720+ }
699 }
721 }
700 }
722 }
701 }
723 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+68−1
@@ -34,6 +34,8 @@ import androidx.compose.material.icons.filled.CheckCircle
34import androidx.compose.material.icons.filled.Close
34import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.Delete
35import androidx.compose.material.icons.filled.Delete
36import androidx.compose.material.icons.filled.Folder
36import androidx.compose.material.icons.filled.Folder
37+import androidx.compose.material.icons.filled.History
38+import androidx.compose.material.icons.filled.Link
37import androidx.compose.material.icons.filled.MoreVert
39import androidx.compose.material.icons.filled.MoreVert
38import androidx.compose.material.icons.filled.PushPin
40import androidx.compose.material.icons.filled.PushPin
39import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Refresh
@@ -69,6 +71,7 @@ import androidx.compose.runtime.mutableStateOf
69import androidx.compose.runtime.remember
71import androidx.compose.runtime.remember
70import androidx.compose.runtime.saveable.rememberSaveable
72import androidx.compose.runtime.saveable.rememberSaveable
71import androidx.compose.runtime.setValue
73import androidx.compose.runtime.setValue
74+import androidx.compose.foundation.shape.CircleShape
72import androidx.compose.ui.Alignment
75import androidx.compose.ui.Alignment
73import androidx.compose.ui.Modifier
76import androidx.compose.ui.Modifier
74import androidx.compose.ui.draw.alpha
77import androidx.compose.ui.draw.alpha
@@ -82,6 +85,7 @@ import androidx.compose.ui.semantics.Role
82import androidx.compose.ui.semantics.contentDescription
85import androidx.compose.ui.semantics.contentDescription
83import androidx.compose.ui.semantics.semantics
86import androidx.compose.ui.semantics.semantics
84import androidx.compose.ui.text.TextRange
87import androidx.compose.ui.text.TextRange
88+import androidx.compose.ui.text.font.FontWeight
85import androidx.compose.ui.text.input.ImeAction
89import androidx.compose.ui.text.input.ImeAction
86import androidx.compose.ui.text.input.TextFieldValue
90import androidx.compose.ui.text.input.TextFieldValue
87import androidx.compose.ui.text.style.TextOverflow
91import androidx.compose.ui.text.style.TextOverflow
@@ -808,7 +812,12 @@ private fun SessionCard(
808 modifier = Modifier.fillMaxWidth(),
812 modifier = Modifier.fillMaxWidth(),
809 verticalAlignment = Alignment.CenterVertically,
813 verticalAlignment = Alignment.CenterVertically,
810 ) {
814 ) {
815+ // Leading monogram avatar so a long list is scannable at a glance instead of a
816+ // wall of identical text rows. Color is derived (stably) from the session id so
817+ // the same session always gets the same tint, while staying within tonal theme
818+ // roles to match the Tokyo Night palette.
819+ SessionAvatar(session)
811− Column(modifier = Modifier.weight(1f)) {
820+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
812 Row(verticalAlignment = Alignment.CenterVertically) {
821 Row(verticalAlignment = Alignment.CenterVertically) {
813 if (unreadCount > 0) {
822 if (unreadCount > 0) {
814 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
823 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
@@ -859,6 +868,25 @@ private fun SessionCard(
859 tint = MaterialTheme.colorScheme.onSurfaceVariant,
868 tint = MaterialTheme.colorScheme.onSurfaceVariant,
860 )
869 )
861 }
870 }
871+ // Server-side state badges (shared / reverted). These are surfaced in
872+ // the chat screen but weren't in the list, so the list never reflected
873+ // them. Muted so they read as status, not as unread emphasis.
874+ if (session.isShared) {
875+ Icon(
876+ Icons.Filled.Link,
877+ contentDescription = stringResource(R.string.session_shared_badge),
878+ modifier = Modifier.padding(end = 4.dp).size(14.dp),
879+ tint = MaterialTheme.colorScheme.primary,
880+ )
881+ }
882+ if (session.isReverted) {
883+ Icon(
884+ Icons.Filled.History,
885+ contentDescription = stringResource(R.string.session_reverted_badge),
886+ modifier = Modifier.padding(end = 4.dp).size(14.dp),
887+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
888+ )
889+ }
862 Text(
890 Text(
863 session.displayTitle,
891 session.displayTitle,
864 style = MaterialTheme.typography.titleMedium,
892 style = MaterialTheme.typography.titleMedium,
@@ -951,6 +979,45 @@ private fun SessionCard(
951 }
979 }
952}
980}
953
981
982+/**
983+ * Leading monogram avatar for a session row: a single letter (preferring the directory's
984+ * initial, since directories are usually project names) on a tonal circle whose color is
985+ * derived stably from the session id. Gives the list visual identity so a long list is
986+ * scannable at a glance, without introducing random non-theme colors.
987+ */
988+@Composable
989+private fun SessionAvatar(session: Session) {
990+ val scheme = MaterialTheme.colorScheme
991+ val letter = remember(session.id, session.displayDirectory, session.displayTitle) {
992+ val source = session.displayDirectory?.firstOrNull()?.takeIf { it.isLetterOrDigit() }
993+ ?: session.displayTitle.firstOrNull()?.takeIf { it.isLetterOrDigit() }
994+ ?: '·'
995+ source.uppercaseChar()
996+ }
997+ // Stable per-id color picked from a few tonal container roles, so the same session always
998+ // gets the same tint while staying within the theme palette (Tokyo Night or dynamic).
999+ val (bg, fg) = remember(session.id, scheme) {
1000+ val options = listOf(
1001+ scheme.primaryContainer to scheme.onPrimaryContainer,
1002+ scheme.secondaryContainer to scheme.onSecondaryContainer,
1003+ scheme.tertiaryContainer to scheme.onTertiaryContainer,
1004+ )
1005+ val idx = ((session.id.hashCode() % options.size) + options.size) % options.size
1006+ options[idx]
1007+ }
1008+ Box(
1009+ modifier = Modifier.size(36.dp).clip(CircleShape).background(bg),
1010+ contentAlignment = Alignment.Center,
1011+ ) {
1012+ Text(
1013+ letter.toString(),
1014+ color = fg,
1015+ style = MaterialTheme.typography.titleMedium,
1016+ fontWeight = FontWeight.SemiBold,
1017+ )
1018+ }
1019+}
1020+
954// Confirmation + Undo for pin/archive toggles. Archiving hides the row from the default
1021// Confirmation + Undo for pin/archive toggles. Archiving hides the row from the default
955// view, so without this the user gets no feedback and no way back. collectLatest so a rapid
1022// view, so without this the user gets no feedback and no way back. collectLatest so a rapid
956// re-toggle supersedes the prior snackbar instead of queuing behind it.
1023// re-toggle supersedes the prior snackbar instead of queuing behind it.
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+39−11
@@ -94,6 +94,7 @@ fun TwoPaneSessionChat(
94 error = sessionListState.error,
94 error = sessionListState.error,
95 sessions = sessionListState.sessions,
95 sessions = sessionListState.sessions,
96 selected = selected,
96 selected = selected,
97+ onAdvance = { id -> selected = id },
97 onClear = { selected = null },
98 onClear = { selected = null },
98 )
99 )
99
100
@@ -216,17 +217,23 @@ private fun containsSession(sessions: List<Session>, id: String): Boolean =
216 sessions.any { it.id == id }
217 sessions.any { it.id == id }
217
218
218/**
219/**
219− * Clears a stale detail-pane selection. Two guards:
220+ * Keeps the detail pane pointed at a real conversation. When the open session disappears from
221+ * the list (deleted, or removed server-side), advance to a neighbor instead of blanking the
222+ * pane — matches the Gmail/Mail master-detail convention where deleting the open conversation
223+ * shows the next one rather than an empty detail. Only falls back to [onClear] when the list
224+ * is genuinely empty (nothing to advance to).
220 *
225 *
226+ * Two guards:
227+ *
221− * - Clear only when the open session is *deleted* — present in the list, then dropped out.
228+ * - Act only when the open session is *removed* — present in the list, then dropped out. A
222− * A freshly created session sets `selected` before its async refresh lands, so the id is
229+ * freshly created session sets `selected` before its async refresh lands, so the id is
223 * legitimately absent for a moment; the `hasAppeared` latch (reset when `selected` changes)
230 * legitimately absent for a moment; the `hasAppeared` latch (reset when `selected` changes)
224− * distinguishes "never appeared yet" (keep) from "appeared, then removed" (clear).
231+ * distinguishes "never appeared yet" (keep) from "appeared, then removed" (advance).
225 * - The `hasAppeared` latch can't survive process death, so a `selected` restored across a
232 * - The `hasAppeared` latch can't survive process death, so a `selected` restored across a
226− * kill whose session was deleted server-side while dead never "appears" and would be kept
233+ * kill whose session was deleted server-side while dead never "appears". Once the first list
227− * forever (permanent load error). Once the first list load completes without the restored
234+ * load completes without the restored target, advance to an existing session (or clear when
228− * target, clear it. Runs once (non-saveable flag, re-arms after death) and only on a
235+ * none). Runs once (non-saveable flag, re-arms after death) and only on a successful,
229− * successful, completed load — not mid-load, and not for a freshly-created pending row.
236+ * completed load — not mid-load, and not for a freshly-created pending row.
230 */
237 */
231@Composable
238@Composable
232private fun StaleSelectionCleanup(
239private fun StaleSelectionCleanup(
@@ -234,13 +241,30 @@ private fun StaleSelectionCleanup(
234 error: String?,
241 error: String?,
235 sessions: List<Session>,
242 sessions: List<Session>,
236 selected: String?,
243 selected: String?,
244+ onAdvance: (String) -> Unit,
237 onClear: () -> Unit,
245 onClear: () -> Unit,
238) {
246) {
239 var hasAppeared by remember(selected) { mutableStateOf(false) }
247 var hasAppeared by remember(selected) { mutableStateOf(false) }
248+ // Snapshot of the list as of the last time the target was present, so when the target
249+ // disappears we can advance to the session that took its row (the next neighbor) rather
250+ // than jumping to an unrelated row. Tracked here rather than derived, because [sessions]
251+ // arriving at this call no longer contains the removed id.
252+ var prevList by remember { mutableStateOf<List<Session>>(emptyList()) }
240 LaunchedEffect(selected, sessions) {
253 LaunchedEffect(selected, sessions) {
241 val target = selected ?: return@LaunchedEffect
254 val target = selected ?: return@LaunchedEffect
242− if (containsSession(sessions, target)) hasAppeared = true
255+ if (containsSession(sessions, target)) {
256+ hasAppeared = true
257+ prevList = sessions
243− else if (hasAppeared) onClear()
258+ } else if (hasAppeared) {
259+ // Removed mid-session: advance to the neighbor that now occupies the target's
260+ // slot, falling back to the prior slot, then to any remaining session.
261+ val prevIndex = prevList.indexOfFirst { it.id == target }.coerceAtLeast(0)
262+ val replacement = sessions.getOrNull(prevIndex)?.id
263+ ?: sessions.getOrNull(prevIndex - 1)?.id
264+ ?: sessions.firstOrNull()?.id
265+ if (replacement != null && replacement != target) onAdvance(replacement) else onClear()
266+ prevList = sessions
267+ }
244 }
268 }
245 var initialSelectionValidated by remember { mutableStateOf(false) }
269 var initialSelectionValidated by remember { mutableStateOf(false) }
246 LaunchedEffect(loading, error, sessions) {
270 LaunchedEffect(loading, error, sessions) {
@@ -248,7 +272,11 @@ private fun StaleSelectionCleanup(
248 if (loading || error != null) return@LaunchedEffect
272 if (loading || error != null) return@LaunchedEffect
249 initialSelectionValidated = true
273 initialSelectionValidated = true
250 val target = selected ?: return@LaunchedEffect
274 val target = selected ?: return@LaunchedEffect
251− if (!containsSession(sessions, target)) onClear()
275+ if (!containsSession(sessions, target)) {
276+ // Process-death case: prefer advancing to an existing session over a blank pane.
277+ val replacement = sessions.firstOrNull()?.id
278+ if (replacement != null) onAdvance(replacement) else onClear()
279+ }
252 }
280 }
253}
281}
254
282
app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+10−2
@@ -14,7 +14,11 @@ import androidx.compose.ui.unit.sp
14 *
14 *
15 * The chat body (markdown text) uses [bodyLarge]/[bodyMedium] with a slightly larger
15 * The chat body (markdown text) uses [bodyLarge]/[bodyMedium] with a slightly larger
16 * line height than the M3 default so token reflow during streaming doesn't feel
16 * line height than the M3 default so token reflow during streaming doesn't feel
17− * cramped, and a comfortable 15–16sp size. Monospace code uses [labelMedium].
17+ * cramped, and a comfortable 15–16sp size. Code is rendered with an explicit
18+ * [androidx.compose.ui.text.font.FontFamily.Monospace] at its call sites (e.g. the
19+ * markdown code block, the tool-call name, the file-browser git-status letter), NOT via
20+ * a typography role — keeping [labelMedium] proportional so general small labels
21+ * (counts, timestamps' siblings, state text) don't inherit a monospace look.
18 */
22 */
19val OpencodeTypography = Typography(
23val OpencodeTypography = Typography(
20 displaySmall = TextStyle(
24 displaySmall = TextStyle(
@@ -72,7 +76,11 @@ val OpencodeTypography = Typography(
72 letterSpacing = 0.1.sp,
76 letterSpacing = 0.1.sp,
73 ),
77 ),
74 labelMedium = TextStyle(
78 labelMedium = TextStyle(
79+ // Proportional (not Monospace): this role is used for general small labels across the
80+ // app (result counts, connection-banner text, attachment labels, picker headers,
81+ // typing indicator, plan progress). Earlier this was FontFamily.Monospace, which
82+ // leaked a code-like look into all of those. Code surfaces set Monospace explicitly.
75− fontFamily = FontFamily.Monospace,
83+ fontFamily = FontFamily.Default,
76 fontWeight = FontWeight.Normal,
84 fontWeight = FontWeight.Normal,
77 fontSize = 12.sp,
85 fontSize = 12.sp,
78 lineHeight = 18.sp,
86 lineHeight = 18.sp,
app/src/main/res/values-es/strings.xml+2
@@ -534,6 +534,8 @@
534 <string name="session_pinned_msg">Sesión fijada</string>
534 <string name="session_pinned_msg">Sesión fijada</string>
535 <string name="session_unpinned_msg">Sesión desfijada</string>
535 <string name="session_unpinned_msg">Sesión desfijada</string>
536 <string name="session_archived_badge">Archivada</string>
536 <string name="session_archived_badge">Archivada</string>
537+ <string name="session_shared_badge">Compartida</string>
538+ <string name="session_reverted_badge">Revertida</string>
537 <string name="sort_active">Orden activo</string>
539 <string name="sort_active">Orden activo</string>
538 <string name="sort_descending">Descendente</string>
540 <string name="sort_descending">Descendente</string>
539 <string name="sort_ascending">Ascendente</string>
541 <string name="sort_ascending">Ascendente</string>
app/src/main/res/values/strings.xml+2
@@ -556,6 +556,8 @@
556 <string name="session_pinned_msg">Session pinned</string>
556 <string name="session_pinned_msg">Session pinned</string>
557 <string name="session_unpinned_msg">Session unpinned</string>
557 <string name="session_unpinned_msg">Session unpinned</string>
558 <string name="session_archived_badge">Archived</string>
558 <string name="session_archived_badge">Archived</string>
559+ <string name="session_shared_badge">Shared</string>
560+ <string name="session_reverted_badge">Reverted</string>
559 <string name="sort_active">Active sort</string>
561 <string name="sort_active">Active sort</string>
560 <string name="sort_descending">Descending</string>
562 <string name="sort_descending">Descending</string>
561 <string name="sort_ascending">Ascending</string>
563 <string name="sort_ascending">Ascending</string>
app/src/test/java/soy/iko/opencode/ui/components/CodeHighlighterLanguageTest.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.ui.graphics.Color
4+import org.junit.Assert.assertEquals
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+
8+class CodeHighlighterLanguageTest {
9+
10+ @Test
11+ fun tagResolvesByNameOrExtension() {
12+ assertEquals(Language.C_FAMILY, syntaxForLanguageTag("kotlin").lang)
13+ assertEquals(Language.C_FAMILY, syntaxForLanguageTag("typescript").lang)
14+ assertEquals(Language.C_FAMILY, syntaxForLanguageTag("ts").lang)
15+ assertEquals(Language.PYTHON, syntaxForLanguageTag("python").lang)
16+ assertEquals(Language.PYTHON, syntaxForLanguageTag("py").lang)
17+ assertEquals(Language.SHELL, syntaxForLanguageTag("bash").lang)
18+ assertEquals(Language.RUBY, syntaxForLanguageTag("ruby").lang)
19+ assertEquals(Language.RUBY, syntaxForLanguageTag("rb").lang)
20+ assertEquals(Language.JSON, syntaxForLanguageTag("json").lang)
21+ assertEquals(Language.MARKUP, syntaxForLanguageTag("html").lang)
22+ assertEquals(Language.NONE, syntaxForLanguageTag("brainfuck").lang)
23+ }
24+
25+ @Test
26+ fun tagIsCaseInsensitiveAndTrimmed() {
27+ assertEquals(Language.C_FAMILY, syntaxForLanguageTag(" Kotlin ").lang)
28+ }
29+
30+ private val palette = HighlightPalette(
31+ keyword = Color.White,
32+ comment = Color.Gray,
33+ string = Color.Green,
34+ number = Color.Blue,
35+ annotation = Color.Cyan,
36+ tag = Color.Red,
37+ base = Color.Black,
38+ )
39+
40+ @Test
41+ fun noneLanguageReturnsPlainString() {
42+ val code = "val x = 1"
43+ val out = highlightCode(code, syntaxForLanguageTag("txt"), palette)
44+ assertEquals(code, out.toString())
45+ assertTrue(out.spanStyles.isEmpty())
46+ }
47+
48+ @Test
49+ fun multilineCodePreservesTextAndAppliesSpans() {
50+ val code = "val a = 1\nval b = 2"
51+ val out = highlightCode(code, syntaxForLanguageTag("kotlin"), palette)
52+ // Highlighting must not alter the characters, only attach spans.
53+ assertEquals(code, out.toString())
54+ // 'val' is a C-family keyword and appears on both lines, so at least two spans exist.
55+ assertTrue("expected spans for two keyword lines", out.spanStyles.size >= 2)
56+ }
57+
58+ @Test
59+ fun rubyNotMisclassifiedAsPython() {
60+ // Regression guard: `.rb` previously fell into the Python keyword set.
61+ assertEquals(Language.RUBY, syntaxForLanguageTag("rb").lang)
62+ val out = highlightCode("def foo\nend", syntaxForLanguageTag("ruby"), palette)
63+ assertEquals("def foo\nend", out.toString())
64+ assertTrue(out.spanStyles.isNotEmpty())
65+ }
66+}