git.iko.soy/opencode-android
on d518ef7 help sign in
802 lines 39 kB history raw
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// Hoisted to module level so it's compiled once, not re-compiled on every ReasoningBlock
91// cache miss (which happens on every token during a streaming reasoning block).
92private val whitespaceRegex = Regex("\\s+")
94/** Build the collapsed preview of [detail]: the first [COLLAPSED_LIMIT] chars, trimmed back to
95 * the last complete line when [isDiff] AND a real char truncation occurred. Trimming on a
96 * diff that was collapsed by the line cap (not the char cap) would drop its final line. */
97private fun collapseDiffPreview(detail: String, isDiff: Boolean): String {
98 val head = detail.take(COLLAPSED_LIMIT)
99 return if (isDiff && detail.length > COLLAPSED_LIMIT) head.substringBeforeLast('\n') else head
102// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
103// resilient decoding) so this stays human-readable; constructed lazily and memoized.
104private val prettyJson: Json = Json { prettyPrint = true }
106/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
107private fun ToolState.titleText(): String? = when (this) {
108 is ToolRunning -> title
109 is ToolCompleted -> title
110 else -> null
113/** The elapsed wall-clock seconds a tool state ran for, when its time window is known.
114 * Returns null while a tool is still pending or when the server didn't report timing. */
115private fun ToolState.durationMs(): Long? {
116 val time = when (this) {
117 is ToolRunning -> time
118 is ToolCompleted -> time
119 is ToolError -> time
120 else -> return null
121 } ?: return null
122 val start = time.start ?: return null
123 val end = time.end ?: return null
124 return (end - start).coerceAtLeast(0L)
127/** Compact human-readable duration for a tool call (e.g. "2.3s", "1m 04s"). Sub-second
128 * runs render in milliseconds so a quick read still shows something distinguishable. */
129fun formatToolDuration(ms: Long): String {
130 if (ms < 1_000) return "${ms}ms"
131 val totalSeconds = ms / 1_000
132 val tenths = (ms % 1_000) / 100
133 if (totalSeconds < 60) {
134 return if (tenths == 0L) "${totalSeconds}s" else "${totalSeconds}.${tenths}s"
135 }
136 val minutes = totalSeconds / 60
137 val seconds = totalSeconds % 60
138 return if (minutes < 60) {
139 "${minutes}m ${seconds.toString().padStart(2, '0')}s"
140 } else {
141 val hours = minutes / 60
142 val mins = minutes % 60
143 "${hours}h ${mins.toString().padStart(2, '0')}m"
144 }
147/**
148 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
149 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
150 *
151 * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
152 * source path the viewer can open; null leaves the chip as a copy-path affordance
153 * (used when no file navigation is wired, e.g. in tests).
154 */
155@Composable
156fun PartView(
157 part: Part,
158 modifier: Modifier = Modifier,
159 isRunning: Boolean = false,
160 imageContext: ImageLoadContext? = null,
161 onOpenFile: ((path: String) -> Unit)? = null,
162) {
163 when (part) {
164 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
165 // A synthetic TextPart is server-generated content (e.g. a compacted-conversation
166 // summary) standing in for real exchanged messages. Mark it with a small muted
167 // chip above the body so a reader can tell it isn't a verbatim assistant reply —
168 // important when reviewing a compacted history where the "summary" framing would
169 // otherwise be indistinguishable from the agent speaking in its own voice.
170 if (part.synthetic) {
171 Row(
172 modifier = modifier.fillMaxWidth(),
173 verticalAlignment = Alignment.CenterVertically,
174 ) {
175 Text(
176 stringResource(R.string.synthetic_chip),
177 style = MaterialTheme.typography.labelSmall,
178 color = MaterialTheme.colorScheme.onSurfaceVariant,
179 modifier = Modifier
180 .clip(MaterialTheme.shapes.extraSmall)
181 .background(MaterialTheme.colorScheme.surfaceVariant)
182 .padding(horizontal = 6.dp, vertical = 2.dp),
183 )
184 }
185 }
186 MarkdownText(part.text, modifier = modifier, streaming = isRunning, imageContext = imageContext)
187 }
188 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
189 is ToolPart -> ToolCallView(part, modifier)
190 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
191 RemoteImage(part, imageContext, modifier)
192 } else if (part.isImage) {
193 // The part is an image but no active connection/profile is available to fetch
194 // it (imageContext is null) or it carries no source/url. Render a chip that
195 // identifies it as an image rather than silently degrading to a generic file
196 // chip, so a user who disconnects mid-conversation understands why the image
197 // isn't shown and knows to reconnect to view it.
198 FileChip(part, modifier, onOpenFile, isImageUnavailable = true)
199 } else {
200 FileChip(part, modifier, onOpenFile)
201 }
202 is StepStartPart -> {} // boundary marker — nothing to draw
203 is StepFinishPart -> StepFinishSummary(part)
204 is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
205 is UnknownPart -> UnknownPartNote(modifier)
206 }
209@Composable
210private fun UnknownPartNote(modifier: Modifier) {
211 // Forward-compat: a part type the client doesn't model. Render a muted note (matching
212 // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
213 // the part vanishing silently — important when a new server release adds a part type.
214 Row(
215 modifier = modifier.fillMaxWidth(),
216 verticalAlignment = Alignment.CenterVertically,
217 ) {
218 Icon(
219 Icons.AutoMirrored.Filled.HelpOutline,
220 contentDescription = null,
221 modifier = Modifier.size(14.dp),
222 tint = MaterialTheme.colorScheme.onSurfaceVariant,
223 )
224 Text(
225 stringResource(R.string.unknown_part),
226 style = MaterialTheme.typography.labelSmall,
227 color = MaterialTheme.colorScheme.onSurfaceVariant,
228 modifier = Modifier.padding(start = 6.dp),
229 )
230 }
233@Composable
234private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
235 if (text.isBlank()) return
236 val context = LocalContext.current
237 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
238 // inherit the positional saveable key of a prior block. A content-prefix key would
239 // change on nearly every token while streaming and snap the block back to collapsed;
240 // when the part has no id, use a constant — the key(part.id, index) wrapper at the
241 // call site already disambiguates sibling parts positionally, so a constant per
242 // block type won't collide.
243 val saveableKey = keyId.ifBlank { "reasoning" }
244 val haptics = LocalHapticFeedback.current
245 // While the reasoning is still streaming, default to expanded so live "Thinking…" is
246 // visible; once complete it collapses. A non-null override records an explicit user
247 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
248 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
249 // Sticky expand: once a block has streamed this session, pin it open after streaming ends
250 // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
251 // stream finishes. A historical block that never streamed this session (streaming is false
252 // from the first composition) never sets streamedThisSession, so it stays collapsed.
253 var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
254 LaunchedEffect(streaming) {
255 if (streaming) streamedThisSession = true
256 else if (streamedThisSession && userOverride == null) userOverride = true
257 }
258 val expanded = userOverride ?: streaming
259 val expandedState = stringResource(R.string.state_expanded)
260 val collapsedState = stringResource(R.string.state_collapsed)
261 val thinkingLabel = stringResource(R.string.thinking)
262 val thoughtsLabel = stringResource(R.string.thoughts)
263 // Hoist the collapsed word count so it can drive both the visible "(N)" label and the
264 // row's a11y stateDescription. Previously the count rendered as a bare "(12)" that
265 // TalkBack read as "12" with no context; folding it into the stateDescription makes the
266 // hidden-content size reachable to screen-reader users.
267 val collapsedWordCount = remember(text) {
268 text.trim().split(whitespaceRegex).count { it.isNotEmpty() }
269 }
270 // Resolve the readable "N words hidden" form once (in the composable body — the semantics
271 // lambda below is not a composable scope, so it can't call pluralStringResource itself).
272 val collapsedWordsLabel = pluralStringResource(
273 R.plurals.reasoning_word_count,
274 collapsedWordCount,
275 collapsedWordCount,
276 )
277 Column(modifier = modifier.fillMaxWidth()) {
278 Row(
279 modifier = Modifier
280 .fillMaxWidth()
281 .defaultMinSize(minHeight = 48.dp)
282 .clickable(role = Role.Button) { userOverride = !expanded }
283 .padding(vertical = 4.dp)
284 .semantics {
285 // When collapsed (and not streaming), append how much reasoning is hidden
286 // so the state reads e.g. "Collapsed, 42 words hidden" — giving a TalkBack
287 // user the same size signal the visible "(42)" gives a sighted user.
288 stateDescription = when {
289 expanded -> expandedState
290 streaming -> collapsedState
291 else -> "$collapsedState, $collapsedWordsLabel"
292 }
293 },
294 verticalAlignment = Alignment.CenterVertically,
295 ) {
296 if (streaming) {
297 CircularProgressIndicator(
298 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
299 strokeWidth = 2.dp,
300 color = MaterialTheme.colorScheme.onSurfaceVariant,
301 )
302 } else {
303 Icon(
304 Icons.Filled.Psychology,
305 contentDescription = null,
306 modifier = Modifier.size(16.dp),
307 tint = MaterialTheme.colorScheme.onSurfaceVariant,
308 )
309 }
310 Text(
311 if (streaming) thinkingLabel else thoughtsLabel,
312 style = MaterialTheme.typography.labelMedium,
313 color = MaterialTheme.colorScheme.onSurfaceVariant,
314 modifier = Modifier.padding(start = 6.dp),
315 )
316 // When collapsed, show a muted word count so the user has a signal of how much
317 // content is hidden before deciding to expand. Skipped while streaming (the
318 // content is growing and the label already says "Thinking…").
319 if (!streaming && !expanded) {
320 Text(
321 "($collapsedWordCount)",
322 style = MaterialTheme.typography.labelSmall,
323 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
324 modifier = Modifier.padding(start = 4.dp),
325 )
326 }
327 }
328 val expandMotion = rememberVisibilityTransitions()
329 AnimatedVisibility(
330 visible = expanded,
331 enter = expandMotion.enter,
332 exit = expandMotion.exit,
333 ) {
334 Column {
335 // Wrap in SelectionContainer so the user can select a portion of the
336 // reasoning (e.g. a single step) instead of the all-or-nothing copy
337 // button below. Matches the markdown text's selectability.
338 SelectionContainer {
339 Text(
340 text,
341 style = MaterialTheme.typography.bodySmall,
342 color = MaterialTheme.colorScheme.onSurfaceVariant,
343 )
344 }
345 }
346 }
347 // Copy affordance lives outside AnimatedVisibility so it's reachable when the
348 // block is collapsed — previously the copy button was inside the expanded content,
349 // leaving a collapsed reasoning block with no copy affordance at all.
350 TextButton(
351 onClick = {
352 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
353 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
354 },
355 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
356 modifier = Modifier
357 .defaultMinSize(minHeight = 48.dp)
358 .semantics(mergeDescendants = true) {},
359 ) {
360 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
361 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
362 }
363 }
366@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
367@Composable
368private fun ToolCallView(part: ToolPart, modifier: Modifier) {
369 val context = LocalContext.current
370 val haptics = LocalHapticFeedback.current
371 Column(
372 modifier = modifier
373 .fillMaxWidth()
374 .clip(MaterialTheme.shapes.small)
375 .background(MaterialTheme.colorScheme.surfaceVariant)
376 .padding(10.dp),
377 ) {
378 Row(verticalAlignment = Alignment.CenterVertically) {
379 ToolStatusIcon(part.state)
380 Text(
381 part.tool,
382 style = MaterialTheme.typography.labelLarge,
383 fontFamily = FontFamily.Monospace,
384 modifier = Modifier.padding(start = 6.dp),
385 )
386 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
387 // reports both start and end. Omitted while still running (no end yet), when
388 // timing wasn't reported, and for sub-second runs (the ms figure is noise for
389 // instant tools like a quick read/glob — see NetworkConfig.toolDurationHideBelowMs).
390 // Placed inline with the tool name so a glance at the row tells both *what* and
391 // *how long*.
392 part.state.durationMs()?.let { ms ->
393 if (ms >= NetworkConfig.toolDurationHideBelowMs) {
394 Text(
395 stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
396 style = MaterialTheme.typography.labelSmall,
397 color = MaterialTheme.colorScheme.onSurfaceVariant,
398 modifier = Modifier.padding(start = 6.dp),
399 )
400 }
401 }
402 }
403 // A human-readable summary of what the tool was asked to do (e.g. "Reading
404 // src/main.kt"), when the server provides one. Sits directly under the tool
405 // name so the user understands the call before diving into input/output.
406 part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
407 Text(
408 title,
409 style = MaterialTheme.typography.bodySmall,
410 color = MaterialTheme.colorScheme.onSurfaceVariant,
411 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
412 )
413 }
414 // A todowrite call carries the agent's task plan as its input; render it as a
415 // checklist rather than raw JSON so each step's progress reads at a glance.
416 val planTodos = remember(part) {
417 if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
418 }
419 if (planTodos.isNotEmpty()) {
420 TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
421 } else {
422 // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
423 // output so the call reads top-to-bottom: name → what → input → result.
424 part.state.inputElement()?.let { input ->
425 val inputLabel = stringResource(R.string.tool_input)
426 val pretty = remember(input) {
427 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
428 }
429 // Expand short inputs by default: for tools like bash/write/edit the command
430 // is the most important info, and hiding it behind a tap inverts the priority.
431 // Longer inputs stay collapsed to avoid dominating the bubble.
432 val inputLines = remember(pretty) { pretty.lines().size }
433 val autoExpand = inputLines <= NetworkConfig.toolInputAutoExpandLineThreshold
434 CollapsibleDetail(
435 label = inputLabel,
436 detail = pretty,
437 isDiff = false,
438 keySuffix = "input",
439 defaultExpanded = autoExpand,
440 // The input block has a label row whose collapse toggle announces its state
441 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
442 // rather than an empty state (the defaults are "").
443 expandedState = stringResource(R.string.state_expanded),
444 collapsedState = stringResource(R.string.state_collapsed),
445 // Mirror the output block's copy affordance so a tool's arguments (e.g. a
446 // bash command or file-write content) can be copied for reuse without manual
447 // text selection.
448 onCopy = {
449 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
450 copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
451 },
452 )
453 }
454 }
455 val detail = when (val s = part.state) {
456 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
457 is ToolError -> s.error ?: stringResource(R.string.error_generic)
458 else -> null
459 }
460 if (detail != null) {
461 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
462 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
463 val isDiff = remember(detail) { looksLikeDiff(detail) }
464 // Collapse when the output exceeds EITHER the char cap or the line cap. A line-based
465 // threshold keeps the collapsed preview a predictable mobile height — a 4000-char
466 // block of short lines can be 60+ lines and fill the screen, defeating the collapse.
467 val lineCount = remember(detail) { detail.lines().size }
468 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
469 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
470 val collapsed = remember(detail, isDiff) {
471 collapseDiffPreview(detail, isDiff)
472 }
473 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
474 val expandedState = stringResource(R.string.state_expanded)
475 val collapsedState = stringResource(R.string.state_collapsed)
476 val display = if (expanded || !exceedsLimit) detail else collapsed
477 // How many lines are hidden while collapsed. detail.lines() splits the whole
478 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
479 // every recomposition, including each streaming update of a running tool.
480 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
481 // 0 (a single long line cut mid-way) falls back to the generic label.
482 val moreLines = remember(detail, collapsed, lineCount) {
483 if (exceedsLimit) lineCount - collapsed.lines().size else 0
484 }
485 CollapsibleDetail(
486 label = null,
487 detail = display,
488 isDiff = isDiff,
489 keySuffix = "output",
490 expanded = expanded || !exceedsLimit,
491 onToggleExpand = if (exceedsLimit) {
492 { expanded = !expanded }
493 } else null,
494 moreLines = moreLines,
495 expandedState = expandedState,
496 collapsedState = collapsedState,
497 onCopy = {
498 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
499 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
500 },
501 diffSaveKey = part.id,
502 isError = part.state is ToolError,
503 )
504 }
505 }
508/**
509 * A collapsible monospace detail block shared by tool input and output. [label] is an
510 * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
511 * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
512 * with an expand/collapse affordance when [onToggleExpand] is supplied.
513 */
514@Composable
515private fun CollapsibleDetail(
516 label: String?,
517 detail: String,
518 isDiff: Boolean,
519 keySuffix: String,
520 expanded: Boolean = false,
521 onToggleExpand: (() -> Unit)? = null,
522 moreLines: Int = 0,
523 expandedState: String = "",
524 collapsedState: String = "",
525 onCopy: (() -> Unit)? = null,
526 diffSaveKey: String? = null,
527 isError: Boolean = false,
528 /** Initial expanded state for the labeled (input) variant. The output variant ignores
529 * this and uses [expanded]/[onToggleExpand] instead. */
530 defaultExpanded: Boolean = false,
531) {
532 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
533 if (label != null) {
534 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(defaultExpanded) }
535 Row(
536 modifier = Modifier
537 .fillMaxWidth()
538 .defaultMinSize(minHeight = 48.dp)
539 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
540 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
541 verticalAlignment = Alignment.CenterVertically,
542 ) {
543 Icon(
544 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
545 contentDescription = null,
546 modifier = Modifier.size(16.dp),
547 tint = MaterialTheme.colorScheme.onSurfaceVariant,
548 )
549 Text(
550 label,
551 style = MaterialTheme.typography.labelSmall,
552 color = MaterialTheme.colorScheme.onSurfaceVariant,
553 modifier = Modifier.padding(start = 4.dp),
554 )
555 }
556 if (!inputExpanded) return@Column
557 }
558 if (isDiff) {
559 DiffView(detail, saveKey = diffSaveKey)
560 } else {
561 // SelectionContainer so the user can select a portion of the output (e.g.
562 // a single line of stdout) instead of only copy-all via the button below.
563 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
564 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
565 // Error output is tinted with errorContainer so a glance conveys the tool failed,
566 // mirroring AgentErrorNote's styling for consistency.
567 val bgColor = if (isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant
568 val textColor = if (isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
569 Surface(
570 color = bgColor,
571 shape = MaterialTheme.shapes.small,
572 modifier = Modifier.fillMaxWidth(),
573 ) {
574 SelectionContainer {
575 Text(
576 detail,
577 style = MaterialTheme.typography.bodySmall,
578 fontFamily = FontFamily.Monospace,
579 color = textColor,
580 softWrap = false,
581 modifier = Modifier
582 .padding(8.dp)
583 .horizontalScroll(rememberScrollState()),
584 )
585 }
586 }
587 }
588 if (onToggleExpand != null) {
589 TextButton(
590 onClick = onToggleExpand,
591 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
592 modifier = Modifier
593 .defaultMinSize(minHeight = 48.dp)
594 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
595 ) {
596 Icon(
597 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
598 contentDescription = null,
599 modifier = Modifier.size(16.dp),
600 )
601 Text(
602 when {
603 expanded -> stringResource(R.string.show_less)
604 moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
605 else -> stringResource(R.string.show_more)
606 },
607 style = MaterialTheme.typography.labelSmall,
608 modifier = Modifier.padding(start = 4.dp),
609 )
610 }
611 }
612 if (onCopy != null) {
613 TextButton(
614 onClick = onCopy,
615 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
616 modifier = Modifier
617 .defaultMinSize(minHeight = 48.dp)
618 .semantics(mergeDescendants = true) {},
619 ) {
620 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
621 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
622 }
623 }
624 }
627@Composable
628private fun ToolStatusIcon(state: ToolState) {
629 when (state) {
630 is ToolPending -> {
631 // Queued, not yet executing: a static clock icon distinguishes it from an
632 // actively-running tool (spinner) so the user can tell what's waiting.
633 val label = stringResource(R.string.tool_queued)
634 Icon(
635 Icons.Filled.Schedule,
636 contentDescription = label,
637 modifier = Modifier.size(16.dp),
638 tint = MaterialTheme.colorScheme.onSurfaceVariant,
639 )
640 }
641 is ToolRunning -> {
642 val label = stringResource(R.string.tool_running)
643 CircularProgressIndicator(
644 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
645 strokeWidth = 2.dp,
646 )
647 }
648 is ToolCompleted ->
649 Icon(
650 Icons.Filled.CheckCircle,
651 contentDescription = stringResource(R.string.tool_completed),
652 modifier = Modifier.size(16.dp),
653 tint = MaterialTheme.colorScheme.primary,
654 )
655 is ToolError ->
656 Icon(
657 Icons.Filled.Error,
658 contentDescription = stringResource(R.string.tool_error),
659 modifier = Modifier.size(16.dp),
660 tint = MaterialTheme.colorScheme.error,
661 )
662 is ToolUnknown ->
663 Icon(
664 Icons.Filled.Bolt,
665 contentDescription = stringResource(R.string.tool_unknown),
666 modifier = Modifier.size(16.dp),
667 )
668 }
671@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
672@Composable
673private fun FileChip(
674 part: FilePart,
675 modifier: Modifier,
676 onOpenFile: ((String) -> Unit)?,
677 isImageUnavailable: Boolean = false,
678) {
679 val context = LocalContext.current
680 val haptics = LocalHapticFeedback.current
681 val path = part.sourcePath ?: part.url ?: part.filename
682 val copyLabel = stringResource(R.string.copy_path)
683 val openLabel = stringResource(R.string.open_file)
684 val imageUnavailableLabel = stringResource(R.string.image_unavailable)
685 // When a source path is available and a navigation callback is wired, tapping the
686 // chip opens the file in the viewer (the action a user tapping a file reference
687 // most likely expects). Long-press still copies the path to the clipboard, so the
688 // copy affordance is preserved without being the default tap action. When no
689 // navigation is wired (e.g. tests), falls back to copy-path on tap.
690 val source = part.sourcePath
691 val opener = onOpenFile
692 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
693 val canOpen = openPath != null
694 val semanticsLabel = when {
695 isImageUnavailable -> imageUnavailableLabel
696 canOpen -> openLabel
697 else -> copyLabel
698 }
699 val displayName = part.filename ?: part.url ?: stringResource(R.string.file)
700 Row(
701 modifier = modifier
702 .clip(MaterialTheme.shapes.small)
703 .background(MaterialTheme.colorScheme.surfaceVariant)
704 .combinedClickable(
705 role = Role.Button,
706 onClick = {
707 val toOpen = openPath
708 if (toOpen != null) {
709 opener?.invoke(toOpen)
710 } else if (!path.isNullOrBlank()) {
711 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
712 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
713 showCopyToast(context, context.getString(R.string.path_copied))
714 }
715 },
716 onLongClick = {
717 if (!path.isNullOrBlank()) {
718 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
719 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
720 showCopyToast(context, context.getString(R.string.path_copied))
721 }
722 },
723 )
724 .semantics { contentDescription = semanticsLabel }
725 .defaultMinSize(minHeight = 48.dp)
726 .padding(horizontal = 12.dp, vertical = 6.dp),
727 verticalAlignment = Alignment.CenterVertically,
728 ) {
729 Icon(
730 Icons.Filled.Description,
731 contentDescription = null,
732 modifier = Modifier.size(16.dp),
733 tint = MaterialTheme.colorScheme.onSurfaceVariant,
734 )
735 Text(
736 if (isImageUnavailable) imageUnavailableLabel else displayName,
737 style = MaterialTheme.typography.bodyMedium,
738 modifier = Modifier.padding(start = 6.dp),
739 )
740 }
743/**
744 * Renders a [StepFinishPart]'s per-step cost/tokens inline at the end of a step.
745 * The per-message aggregate summary in [MessageBubble.AssistantBlock] covers the
746 * whole assistant message; this surfaces per-step usage so a multi-step run (e.g.
747 * a main agent + a sub-agent) shows the cost breakdown per step. Compact and muted
748 * to read as a footnote rather than a primary element. Returns nothing when the
749 * step reported neither cost nor tokens (some steps report no usage).
750 *
751 * Collapsed by default behind a small toggle so a multi-step response isn't
752 * cluttered with a cost line after each step; the aggregate in the footer is the
753 * primary signal, and a user who wants the per-step breakdown can expand it.
754 */
755@Composable
756private fun StepFinishSummary(part: StepFinishPart) {
757 val cost = part.cost
758 val tokens = part.tokens
759 if (cost == null && tokens == null) return
760 val tokenFormat = stringResource(R.string.tokens_in_out)
761 val costShort = stringResource(R.string.step_cost_short)
762 val costLong = stringResource(R.string.step_cost_long)
763 val summary = remember(cost, tokens, tokenFormat, costShort, costLong) {
764 buildList {
765 tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
766 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
767 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
768 } ?: return
769 val expandedState = stringResource(R.string.state_expanded)
770 val collapsedState = stringResource(R.string.state_collapsed)
771 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
772 Row(
773 modifier = Modifier
774 .fillMaxWidth()
775 .defaultMinSize(minHeight = 48.dp)
776 .clickable(role = Role.Button) { expanded = !expanded }
777 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
778 verticalAlignment = Alignment.CenterVertically,
779 ) {
780 Icon(
781 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
782 contentDescription = null,
783 modifier = Modifier.size(14.dp),
784 tint = MaterialTheme.colorScheme.onSurfaceVariant,
785 )
786 if (expanded) {
787 Text(
788 summary,
789 style = MaterialTheme.typography.labelSmall,
790 color = MaterialTheme.colorScheme.onSurfaceVariant,
791 modifier = Modifier.padding(start = 4.dp, top = 2.dp),
792 )
793 } else {
794 Text(
795 stringResource(R.string.step_summary_collapsed),
796 style = MaterialTheme.typography.labelSmall,
797 color = MaterialTheme.colorScheme.onSurfaceVariant,
798 modifier = Modifier.padding(start = 4.dp),
799 )
800 }
801 }