1package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
7import androidx.compose.foundation.horizontalScroll
8import androidx.compose.foundation.rememberScrollState
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.defaultMinSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.text.selection.SelectionContainer
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.HelpOutline
18import androidx.compose.material.icons.filled.Bolt
19import androidx.compose.material.icons.filled.CheckCircle
20import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.Description
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Psychology
26import androidx.compose.material.icons.filled.Schedule
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.Icon
29import androidx.compose.material3.IconButton
30import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.Surface
32import androidx.compose.material3.Text
33import androidx.compose.material3.TextButton
34import androidx.compose.runtime.Composable
35import androidx.compose.runtime.LaunchedEffect
36import androidx.compose.runtime.getValue
37import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.remember
39import androidx.compose.runtime.saveable.rememberSaveable
40import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
43import androidx.compose.ui.draw.clip
44import androidx.compose.ui.hapticfeedback.HapticFeedbackType
45import androidx.compose.ui.platform.LocalContext
46import androidx.compose.ui.platform.LocalHapticFeedback
47import androidx.compose.ui.semantics.Role
48import androidx.compose.ui.semantics.contentDescription
49import androidx.compose.ui.semantics.semantics
50import androidx.compose.ui.semantics.stateDescription
51import androidx.compose.ui.text.font.FontFamily
52import androidx.compose.ui.res.pluralStringResource
53import androidx.compose.ui.res.stringResource
54import androidx.compose.ui.unit.dp
55import kotlinx.serialization.json.Json
56import kotlinx.serialization.json.JsonElement
57import soy.iko.opencode.data.model.AgentPart
58import soy.iko.opencode.data.model.FilePart
59import soy.iko.opencode.data.model.Part
60import soy.iko.opencode.data.model.ReasoningPart
61import soy.iko.opencode.data.model.StepFinishPart
62import soy.iko.opencode.data.model.StepStartPart
63import soy.iko.opencode.data.model.TextPart
64import soy.iko.opencode.data.model.ToolCompleted
65import soy.iko.opencode.data.model.ToolError
66import soy.iko.opencode.data.model.ToolPart
67import soy.iko.opencode.data.model.ToolPending
68import soy.iko.opencode.data.network.NetworkConfig
69import soy.iko.opencode.data.model.ToolRunning
70import soy.iko.opencode.data.model.ToolState
71import soy.iko.opencode.data.model.ToolUnknown
72import soy.iko.opencode.data.model.TODO_WRITE_TOOL
73import soy.iko.opencode.data.model.UnknownPart
74import soy.iko.opencode.data.model.inputElement
75import soy.iko.opencode.data.model.parseTodos
76import soy.iko.opencode.data.model.sourcePath
77import soy.iko.opencode.ui.components.DiffView
78import soy.iko.opencode.ui.components.ImageLoadContext
79import soy.iko.opencode.ui.components.MarkdownText
80import soy.iko.opencode.ui.components.RemoteImage
81import soy.iko.opencode.ui.components.copyToClipboard
82import soy.iko.opencode.ui.components.isImage
83import soy.iko.opencode.ui.components.looksLikeDiff
84import soy.iko.opencode.ui.components.rememberVisibilityTransitions
85import soy.iko.opencode.ui.components.showCopyToast
86import soy.iko.opencode.R
88private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
90/** Build the collapsed preview of [detail]: the first [COLLAPSED_LIMIT] chars, trimmed back to
91 * the last complete line when [isDiff] AND a real char truncation occurred. Trimming on a
92 * diff that was collapsed by the line cap (not the char cap) would drop its final line. */
93private fun collapseDiffPreview(detail: String, isDiff: Boolean): String {
94 val head = detail.take(COLLAPSED_LIMIT)
95 return if (isDiff && detail.length > COLLAPSED_LIMIT) head.substringBeforeLast('\n') else head
98// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
99// resilient decoding) so this stays human-readable; constructed lazily and memoized.
100private val prettyJson: Json = Json { prettyPrint = true }
102/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
103private fun ToolState.titleText(): String? = when (this) {
104 is ToolRunning -> title
105 is ToolCompleted -> title
109/** The elapsed wall-clock seconds a tool state ran for, when its time window is known.
110 * Returns null while a tool is still pending or when the server didn't report timing. */
111private fun ToolState.durationMs(): Long? {
112 val time = when (this) {
113 is ToolRunning -> time
114 is ToolCompleted -> time
118 val start = time.start ?: return null
119 val end = time.end ?: return null
120 return (end - start).coerceAtLeast(0L)
123/** Compact human-readable duration for a tool call (e.g. "2.3s", "1m 04s"). Sub-second
124 * runs render in milliseconds so a quick read still shows something distinguishable. */
125fun formatToolDuration(ms: Long): String {
126 if (ms < 1_000) return "${ms}ms"
127 val totalSeconds = ms / 1_000
128 val tenths = (ms % 1_000) / 100
129 if (totalSeconds < 60) {
130 return if (tenths == 0L) "${totalSeconds}s" else "${totalSeconds}.${tenths}s"
132 val minutes = totalSeconds / 60
133 val seconds = totalSeconds % 60
134 return if (minutes < 60) {
135 "${minutes}m ${seconds.toString().padStart(2, '0')}s"
137 val hours = minutes / 60
138 val mins = minutes % 60
139 "${hours}h ${mins.toString().padStart(2, '0')}m"
144 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
145 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
147 * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
148 * source path the viewer can open; null leaves the chip as a copy-path affordance
149 * (used when no file navigation is wired, e.g. in tests).
154 modifier: Modifier = Modifier,
155 isRunning: Boolean = false,
156 imageContext: ImageLoadContext? = null,
157 onOpenFile: ((path: String) -> Unit)? = null,
160 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
161 // A synthetic TextPart is server-generated content (e.g. a compacted-conversation
162 // summary) standing in for real exchanged messages. Mark it with a small muted
163 // chip above the body so a reader can tell it isn't a verbatim assistant reply —
164 // important when reviewing a compacted history where the "summary" framing would
165 // otherwise be indistinguishable from the agent speaking in its own voice.
166 if (part.synthetic) {
168 modifier = modifier.fillMaxWidth(),
169 verticalAlignment = Alignment.CenterVertically,
172 stringResource(R.string.synthetic_chip),
173 style = MaterialTheme.typography.labelSmall,
174 color = MaterialTheme.colorScheme.onSurfaceVariant,
176 .clip(MaterialTheme.shapes.extraSmall)
177 .background(MaterialTheme.colorScheme.surfaceVariant)
178 .padding(horizontal = 6.dp, vertical = 2.dp),
182 MarkdownText(part.text, modifier = modifier, streaming = isRunning, imageContext = imageContext)
184 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
185 is ToolPart -> ToolCallView(part, modifier)
186 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
187 RemoteImage(part, imageContext, modifier)
188 } else if (part.isImage) {
189 // The part is an image but no active connection/profile is available to fetch
190 // it (imageContext is null) or it carries no source/url. Render a chip that
191 // identifies it as an image rather than silently degrading to a generic file
192 // chip, so a user who disconnects mid-conversation understands why the image
193 // isn't shown and knows to reconnect to view it.
194 FileChip(part, modifier, onOpenFile, isImageUnavailable = true)
196 FileChip(part, modifier, onOpenFile)
198 is StepStartPart -> {} // boundary marker — nothing to draw
199 is StepFinishPart -> StepFinishSummary(part)
200 is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
201 is UnknownPart -> UnknownPartNote(modifier)
206private fun UnknownPartNote(modifier: Modifier) {
207 // Forward-compat: a part type the client doesn't model. Render a muted note (matching
208 // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
209 // the part vanishing silently — important when a new server release adds a part type.
211 modifier = modifier.fillMaxWidth(),
212 verticalAlignment = Alignment.CenterVertically,
215 Icons.AutoMirrored.Filled.HelpOutline,
216 contentDescription = null,
217 modifier = Modifier.size(14.dp),
218 tint = MaterialTheme.colorScheme.onSurfaceVariant,
221 stringResource(R.string.unknown_part),
222 style = MaterialTheme.typography.labelSmall,
223 color = MaterialTheme.colorScheme.onSurfaceVariant,
224 modifier = Modifier.padding(start = 6.dp),
230private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
231 if (text.isBlank()) return
232 val context = LocalContext.current
233 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
234 // inherit the positional saveable key of a prior block. A content-prefix key would
235 // change on nearly every token while streaming and snap the block back to collapsed;
236 // when the part has no id, use a constant — the key(part.id, index) wrapper at the
237 // call site already disambiguates sibling parts positionally, so a constant per
238 // block type won't collide.
239 val saveableKey = keyId.ifBlank { "reasoning" }
240 val haptics = LocalHapticFeedback.current
241 // While the reasoning is still streaming, default to expanded so live "Thinking…" is
242 // visible; once complete it collapses. A non-null override records an explicit user
243 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
244 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
245 // Sticky expand: once a block has streamed this session, pin it open after streaming ends
246 // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
247 // stream finishes. A historical block that never streamed this session (streaming is false
248 // from the first composition) never sets streamedThisSession, so it stays collapsed.
249 var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
250 LaunchedEffect(streaming) {
251 if (streaming) streamedThisSession = true
252 else if (streamedThisSession && userOverride == null) userOverride = true
254 val expanded = userOverride ?: streaming
255 val expandedState = stringResource(R.string.state_expanded)
256 val collapsedState = stringResource(R.string.state_collapsed)
257 val thinkingLabel = stringResource(R.string.thinking)
258 val thoughtsLabel = stringResource(R.string.thoughts)
259 // Hoist the collapsed word count so it can drive both the visible "(N)" label and the
260 // row's a11y stateDescription. Previously the count rendered as a bare "(12)" that
261 // TalkBack read as "12" with no context; folding it into the stateDescription makes the
262 // hidden-content size reachable to screen-reader users.
263 val collapsedWordCount = remember(text) {
264 text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
266 // Resolve the readable "N words hidden" form once (in the composable body — the semantics
267 // lambda below is not a composable scope, so it can't call pluralStringResource itself).
268 val collapsedWordsLabel = pluralStringResource(
269 R.plurals.reasoning_word_count,
273 Column(modifier = modifier.fillMaxWidth()) {
277 .defaultMinSize(minHeight = 48.dp)
278 .clickable(role = Role.Button) { userOverride = !expanded }
279 .padding(vertical = 4.dp)
281 // When collapsed (and not streaming), append how much reasoning is hidden
282 // so the state reads e.g. "Collapsed, 42 words hidden" — giving a TalkBack
283 // user the same size signal the visible "(42)" gives a sighted user.
284 stateDescription = when {
285 expanded -> expandedState
286 streaming -> collapsedState
287 else -> "$collapsedState, $collapsedWordsLabel"
290 verticalAlignment = Alignment.CenterVertically,
293 CircularProgressIndicator(
294 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
296 color = MaterialTheme.colorScheme.onSurfaceVariant,
300 Icons.Filled.Psychology,
301 contentDescription = null,
302 modifier = Modifier.size(16.dp),
303 tint = MaterialTheme.colorScheme.onSurfaceVariant,
307 if (streaming) thinkingLabel else thoughtsLabel,
308 style = MaterialTheme.typography.labelMedium,
309 color = MaterialTheme.colorScheme.onSurfaceVariant,
310 modifier = Modifier.padding(start = 6.dp),
312 // When collapsed, show a muted word count so the user has a signal of how much
313 // content is hidden before deciding to expand. Skipped while streaming (the
314 // content is growing and the label already says "Thinking…").
315 if (!streaming && !expanded) {
317 "($collapsedWordCount)",
318 style = MaterialTheme.typography.labelSmall,
319 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
320 modifier = Modifier.padding(start = 4.dp),
324 val expandMotion = rememberVisibilityTransitions()
327 enter = expandMotion.enter,
328 exit = expandMotion.exit,
331 // Wrap in SelectionContainer so the user can select a portion of the
332 // reasoning (e.g. a single step) instead of the all-or-nothing copy
333 // button below. Matches the markdown text's selectability.
337 style = MaterialTheme.typography.bodySmall,
338 color = MaterialTheme.colorScheme.onSurfaceVariant,
343 // Copy affordance lives outside AnimatedVisibility so it's reachable when the
344 // block is collapsed — previously the copy button was inside the expanded content,
345 // leaving a collapsed reasoning block with no copy affordance at all.
348 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
349 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
351 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
353 .defaultMinSize(minHeight = 48.dp)
354 .semantics(mergeDescendants = true) {},
356 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
357 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
362@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
364private fun ToolCallView(part: ToolPart, modifier: Modifier) {
365 val context = LocalContext.current
366 val haptics = LocalHapticFeedback.current
370 .clip(MaterialTheme.shapes.small)
371 .background(MaterialTheme.colorScheme.surfaceVariant)
374 Row(verticalAlignment = Alignment.CenterVertically) {
375 ToolStatusIcon(part.state)
378 style = MaterialTheme.typography.labelLarge,
379 fontFamily = FontFamily.Monospace,
380 modifier = Modifier.padding(start = 6.dp),
382 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
383 // reports both start and end. Omitted while still running (no end yet), when
384 // timing wasn't reported, and for sub-second runs (the ms figure is noise for
385 // instant tools like a quick read/glob — see NetworkConfig.toolDurationHideBelowMs).
386 // Placed inline with the tool name so a glance at the row tells both *what* and
388 part.state.durationMs()?.let { ms ->
389 if (ms >= NetworkConfig.toolDurationHideBelowMs) {
391 stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
392 style = MaterialTheme.typography.labelSmall,
393 color = MaterialTheme.colorScheme.onSurfaceVariant,
394 modifier = Modifier.padding(start = 6.dp),
399 // A human-readable summary of what the tool was asked to do (e.g. "Reading
400 // src/main.kt"), when the server provides one. Sits directly under the tool
401 // name so the user understands the call before diving into input/output.
402 part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
405 style = MaterialTheme.typography.bodySmall,
406 color = MaterialTheme.colorScheme.onSurfaceVariant,
407 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
410 // A todowrite call carries the agent's task plan as its input; render it as a
411 // checklist rather than raw JSON so each step's progress reads at a glance.
412 val planTodos = remember(part) {
413 if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
415 if (planTodos.isNotEmpty()) {
416 TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
418 // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
419 // output so the call reads top-to-bottom: name → what → input → result.
420 part.state.inputElement()?.let { input ->
421 val inputLabel = stringResource(R.string.tool_input)
422 val pretty = remember(input) {
423 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
425 // Expand short inputs by default: for tools like bash/write/edit the command
426 // is the most important info, and hiding it behind a tap inverts the priority.
427 // Longer inputs stay collapsed to avoid dominating the bubble.
428 val inputLines = remember(pretty) { pretty.lines().size }
429 val autoExpand = inputLines <= NetworkConfig.toolInputAutoExpandLineThreshold
435 defaultExpanded = autoExpand,
436 // The input block has a label row whose collapse toggle announces its state
437 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
438 // rather than an empty state (the defaults are "").
439 expandedState = stringResource(R.string.state_expanded),
440 collapsedState = stringResource(R.string.state_collapsed),
441 // Mirror the output block's copy affordance so a tool's arguments (e.g. a
442 // bash command or file-write content) can be copied for reuse without manual
445 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
446 copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
451 val detail = when (val s = part.state) {
452 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
453 is ToolError -> s.error ?: stringResource(R.string.error_generic)
456 if (detail != null) {
457 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
458 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
459 val isDiff = remember(detail) { looksLikeDiff(detail) }
460 // Collapse when the output exceeds EITHER the char cap or the line cap. A line-based
461 // threshold keeps the collapsed preview a predictable mobile height — a 4000-char
462 // block of short lines can be 60+ lines and fill the screen, defeating the collapse.
463 val lineCount = remember(detail) { detail.lines().size }
464 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
465 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
466 val collapsed = remember(detail, isDiff) {
467 collapseDiffPreview(detail, isDiff)
469 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
470 val expandedState = stringResource(R.string.state_expanded)
471 val collapsedState = stringResource(R.string.state_collapsed)
472 val display = if (expanded || !exceedsLimit) detail else collapsed
473 // How many lines are hidden while collapsed. detail.lines() splits the whole
474 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
475 // every recomposition, including each streaming update of a running tool.
476 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
477 // 0 (a single long line cut mid-way) falls back to the generic label.
478 val moreLines = remember(detail, collapsed, lineCount) {
479 if (exceedsLimit) lineCount - collapsed.lines().size else 0
485 keySuffix = "output",
486 expanded = expanded || !exceedsLimit,
487 onToggleExpand = if (exceedsLimit) {
488 { expanded = !expanded }
490 moreLines = moreLines,
491 expandedState = expandedState,
492 collapsedState = collapsedState,
494 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
495 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
497 diffSaveKey = part.id,
498 isError = part.state is ToolError,
505 * A collapsible monospace detail block shared by tool input and output. [label] is an
506 * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
507 * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
508 * with an expand/collapse affordance when [onToggleExpand] is supplied.
511private fun CollapsibleDetail(
516 expanded: Boolean = false,
517 onToggleExpand: (() -> Unit)? = null,
519 expandedState: String = "",
520 collapsedState: String = "",
521 onCopy: (() -> Unit)? = null,
522 diffSaveKey: String? = null,
523 isError: Boolean = false,
524 /** Initial expanded state for the labeled (input) variant. The output variant ignores
525 * this and uses [expanded]/[onToggleExpand] instead. */
526 defaultExpanded: Boolean = false,
528 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
530 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(defaultExpanded) }
534 .defaultMinSize(minHeight = 48.dp)
535 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
536 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
537 verticalAlignment = Alignment.CenterVertically,
540 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
541 contentDescription = null,
542 modifier = Modifier.size(16.dp),
543 tint = MaterialTheme.colorScheme.onSurfaceVariant,
547 style = MaterialTheme.typography.labelSmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549 modifier = Modifier.padding(start = 4.dp),
552 if (!inputExpanded) return@Column
555 DiffView(detail, saveKey = diffSaveKey)
557 // SelectionContainer so the user can select a portion of the output (e.g.
558 // a single line of stdout) instead of only copy-all via the button below.
559 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
560 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
561 // Error output is tinted with errorContainer so a glance conveys the tool failed,
562 // mirroring AgentErrorNote's styling for consistency.
563 val bgColor = if (isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant
564 val textColor = if (isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
567 shape = MaterialTheme.shapes.small,
568 modifier = Modifier.fillMaxWidth(),
573 style = MaterialTheme.typography.bodySmall,
574 fontFamily = FontFamily.Monospace,
579 .horizontalScroll(rememberScrollState()),
584 if (onToggleExpand != null) {
586 onClick = onToggleExpand,
587 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
589 .defaultMinSize(minHeight = 48.dp)
590 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
593 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
594 contentDescription = null,
595 modifier = Modifier.size(16.dp),
599 expanded -> stringResource(R.string.show_less)
600 moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
601 else -> stringResource(R.string.show_more)
603 style = MaterialTheme.typography.labelSmall,
604 modifier = Modifier.padding(start = 4.dp),
608 if (onCopy != null) {
611 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
613 .defaultMinSize(minHeight = 48.dp)
614 .semantics(mergeDescendants = true) {},
616 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
617 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
624private fun ToolStatusIcon(state: ToolState) {
627 // Queued, not yet executing: a static clock icon distinguishes it from an
628 // actively-running tool (spinner) so the user can tell what's waiting.
629 val label = stringResource(R.string.tool_queued)
631 Icons.Filled.Schedule,
632 contentDescription = label,
633 modifier = Modifier.size(16.dp),
634 tint = MaterialTheme.colorScheme.onSurfaceVariant,
638 val label = stringResource(R.string.tool_running)
639 CircularProgressIndicator(
640 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
646 Icons.Filled.CheckCircle,
647 contentDescription = stringResource(R.string.tool_completed),
648 modifier = Modifier.size(16.dp),
649 tint = MaterialTheme.colorScheme.primary,
654 contentDescription = stringResource(R.string.tool_error),
655 modifier = Modifier.size(16.dp),
656 tint = MaterialTheme.colorScheme.error,
661 contentDescription = stringResource(R.string.tool_unknown),
662 modifier = Modifier.size(16.dp),
667@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
672 onOpenFile: ((String) -> Unit)?,
673 isImageUnavailable: Boolean = false,
675 val context = LocalContext.current
676 val haptics = LocalHapticFeedback.current
677 val path = part.sourcePath ?: part.url ?: part.filename
678 val copyLabel = stringResource(R.string.copy_path)
679 val openLabel = stringResource(R.string.open_file)
680 val imageUnavailableLabel = stringResource(R.string.image_unavailable)
681 // When a source path is available and a navigation callback is wired, tapping the
682 // chip opens the file in the viewer (the action a user tapping a file reference
683 // most likely expects). Long-press still copies the path to the clipboard, so the
684 // copy affordance is preserved without being the default tap action. When no
685 // navigation is wired (e.g. tests), falls back to copy-path on tap.
686 val source = part.sourcePath
687 val opener = onOpenFile
688 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
689 val canOpen = openPath != null
690 val semanticsLabel = when {
691 isImageUnavailable -> imageUnavailableLabel
695 val displayName = part.filename ?: part.url ?: stringResource(R.string.file)
698 .clip(MaterialTheme.shapes.small)
699 .background(MaterialTheme.colorScheme.surfaceVariant)
703 val toOpen = openPath
704 if (toOpen != null) {
705 opener?.invoke(toOpen)
706 } else if (!path.isNullOrBlank()) {
707 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
708 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
709 showCopyToast(context, context.getString(R.string.path_copied))
713 if (!path.isNullOrBlank()) {
714 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
715 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
716 showCopyToast(context, context.getString(R.string.path_copied))
720 .semantics { contentDescription = semanticsLabel }
721 .defaultMinSize(minHeight = 48.dp)
722 .padding(horizontal = 12.dp, vertical = 6.dp),
723 verticalAlignment = Alignment.CenterVertically,
726 Icons.Filled.Description,
727 contentDescription = null,
728 modifier = Modifier.size(16.dp),
729 tint = MaterialTheme.colorScheme.onSurfaceVariant,
732 if (isImageUnavailable) imageUnavailableLabel else displayName,
733 style = MaterialTheme.typography.bodyMedium,
734 modifier = Modifier.padding(start = 6.dp),
740 * Renders a [StepFinishPart]'s per-step cost/tokens inline at the end of a step.
741 * The per-message aggregate summary in [MessageBubble.AssistantBlock] covers the
742 * whole assistant message; this surfaces per-step usage so a multi-step run (e.g.
743 * a main agent + a sub-agent) shows the cost breakdown per step. Compact and muted
744 * to read as a footnote rather than a primary element. Returns nothing when the
745 * step reported neither cost nor tokens (some steps report no usage).
747 * Collapsed by default behind a small toggle so a multi-step response isn't
748 * cluttered with a cost line after each step; the aggregate in the footer is the
749 * primary signal, and a user who wants the per-step breakdown can expand it.
752private fun StepFinishSummary(part: StepFinishPart) {
754 val tokens = part.tokens
755 if (cost == null && tokens == null) return
756 val tokenFormat = stringResource(R.string.tokens_in_out)
757 val costShort = stringResource(R.string.step_cost_short)
758 val costLong = stringResource(R.string.step_cost_long)
759 val summary = remember(cost, tokens, tokenFormat, costShort, costLong) {
761 tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
762 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
763 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
765 val expandedState = stringResource(R.string.state_expanded)
766 val collapsedState = stringResource(R.string.state_collapsed)
767 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
771 .defaultMinSize(minHeight = 48.dp)
772 .clickable(role = Role.Button) { expanded = !expanded }
773 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
774 verticalAlignment = Alignment.CenterVertically,
777 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
778 contentDescription = null,
779 modifier = Modifier.size(14.dp),
780 tint = MaterialTheme.colorScheme.onSurfaceVariant,
785 style = MaterialTheme.typography.labelSmall,
786 color = MaterialTheme.colorScheme.onSurfaceVariant,
787 modifier = Modifier.padding(start = 4.dp, top = 2.dp),
791 stringResource(R.string.step_summary_collapsed),
792 style = MaterialTheme.typography.labelSmall,
793 color = MaterialTheme.colorScheme.onSurfaceVariant,
794 modifier = Modifier.padding(start = 4.dp),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list