git.iko.soy/opencode-android
on 2794fda help sign in
798 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/** 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
106 else -> null
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
115 is ToolError -> time
116 else -> return null
117 } ?: return null
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"
131 }
132 val minutes = totalSeconds / 60
133 val seconds = totalSeconds % 60
134 return if (minutes < 60) {
135 "${minutes}m ${seconds.toString().padStart(2, '0')}s"
136 } else {
137 val hours = minutes / 60
138 val mins = minutes % 60
139 "${hours}h ${mins.toString().padStart(2, '0')}m"
140 }
143/**
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.
146 *
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).
150 */
151@Composable
152fun PartView(
153 part: Part,
154 modifier: Modifier = Modifier,
155 isRunning: Boolean = false,
156 imageContext: ImageLoadContext? = null,
157 onOpenFile: ((path: String) -> Unit)? = null,
158) {
159 when (part) {
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) {
167 Row(
168 modifier = modifier.fillMaxWidth(),
169 verticalAlignment = Alignment.CenterVertically,
170 ) {
171 Text(
172 stringResource(R.string.synthetic_chip),
173 style = MaterialTheme.typography.labelSmall,
174 color = MaterialTheme.colorScheme.onSurfaceVariant,
175 modifier = Modifier
176 .clip(MaterialTheme.shapes.extraSmall)
177 .background(MaterialTheme.colorScheme.surfaceVariant)
178 .padding(horizontal = 6.dp, vertical = 2.dp),
179 )
180 }
181 }
182 MarkdownText(part.text, modifier = modifier, streaming = isRunning, imageContext = imageContext)
183 }
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)
195 } else {
196 FileChip(part, modifier, onOpenFile)
197 }
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)
202 }
205@Composable
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.
210 Row(
211 modifier = modifier.fillMaxWidth(),
212 verticalAlignment = Alignment.CenterVertically,
213 ) {
214 Icon(
215 Icons.AutoMirrored.Filled.HelpOutline,
216 contentDescription = null,
217 modifier = Modifier.size(14.dp),
218 tint = MaterialTheme.colorScheme.onSurfaceVariant,
219 )
220 Text(
221 stringResource(R.string.unknown_part),
222 style = MaterialTheme.typography.labelSmall,
223 color = MaterialTheme.colorScheme.onSurfaceVariant,
224 modifier = Modifier.padding(start = 6.dp),
225 )
226 }
229@Composable
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
253 }
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() }
265 }
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,
270 collapsedWordCount,
271 collapsedWordCount,
272 )
273 Column(modifier = modifier.fillMaxWidth()) {
274 Row(
275 modifier = Modifier
276 .fillMaxWidth()
277 .defaultMinSize(minHeight = 48.dp)
278 .clickable(role = Role.Button) { userOverride = !expanded }
279 .padding(vertical = 4.dp)
280 .semantics {
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"
288 }
289 },
290 verticalAlignment = Alignment.CenterVertically,
291 ) {
292 if (streaming) {
293 CircularProgressIndicator(
294 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
295 strokeWidth = 2.dp,
296 color = MaterialTheme.colorScheme.onSurfaceVariant,
297 )
298 } else {
299 Icon(
300 Icons.Filled.Psychology,
301 contentDescription = null,
302 modifier = Modifier.size(16.dp),
303 tint = MaterialTheme.colorScheme.onSurfaceVariant,
304 )
305 }
306 Text(
307 if (streaming) thinkingLabel else thoughtsLabel,
308 style = MaterialTheme.typography.labelMedium,
309 color = MaterialTheme.colorScheme.onSurfaceVariant,
310 modifier = Modifier.padding(start = 6.dp),
311 )
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) {
316 Text(
317 "($collapsedWordCount)",
318 style = MaterialTheme.typography.labelSmall,
319 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
320 modifier = Modifier.padding(start = 4.dp),
321 )
322 }
323 }
324 val expandMotion = rememberVisibilityTransitions()
325 AnimatedVisibility(
326 visible = expanded,
327 enter = expandMotion.enter,
328 exit = expandMotion.exit,
329 ) {
330 Column {
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.
334 SelectionContainer {
335 Text(
336 text,
337 style = MaterialTheme.typography.bodySmall,
338 color = MaterialTheme.colorScheme.onSurfaceVariant,
339 )
340 }
341 }
342 }
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.
346 TextButton(
347 onClick = {
348 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
349 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
350 },
351 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
352 modifier = Modifier
353 .defaultMinSize(minHeight = 48.dp)
354 .semantics(mergeDescendants = true) {},
355 ) {
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))
358 }
359 }
362@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
363@Composable
364private fun ToolCallView(part: ToolPart, modifier: Modifier) {
365 val context = LocalContext.current
366 val haptics = LocalHapticFeedback.current
367 Column(
368 modifier = modifier
369 .fillMaxWidth()
370 .clip(MaterialTheme.shapes.small)
371 .background(MaterialTheme.colorScheme.surfaceVariant)
372 .padding(10.dp),
373 ) {
374 Row(verticalAlignment = Alignment.CenterVertically) {
375 ToolStatusIcon(part.state)
376 Text(
377 part.tool,
378 style = MaterialTheme.typography.labelLarge,
379 fontFamily = FontFamily.Monospace,
380 modifier = Modifier.padding(start = 6.dp),
381 )
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
387 // *how long*.
388 part.state.durationMs()?.let { ms ->
389 if (ms >= NetworkConfig.toolDurationHideBelowMs) {
390 Text(
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),
395 )
396 }
397 }
398 }
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 ->
403 Text(
404 title,
405 style = MaterialTheme.typography.bodySmall,
406 color = MaterialTheme.colorScheme.onSurfaceVariant,
407 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
408 )
409 }
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()
414 }
415 if (planTodos.isNotEmpty()) {
416 TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
417 } else {
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())
424 }
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
430 CollapsibleDetail(
431 label = inputLabel,
432 detail = pretty,
433 isDiff = false,
434 keySuffix = "input",
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
443 // text selection.
444 onCopy = {
445 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
446 copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
447 },
448 )
449 }
450 }
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)
454 else -> null
455 }
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)
468 }
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
480 }
481 CollapsibleDetail(
482 label = null,
483 detail = display,
484 isDiff = isDiff,
485 keySuffix = "output",
486 expanded = expanded || !exceedsLimit,
487 onToggleExpand = if (exceedsLimit) {
488 { expanded = !expanded }
489 } else null,
490 moreLines = moreLines,
491 expandedState = expandedState,
492 collapsedState = collapsedState,
493 onCopy = {
494 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
495 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
496 },
497 diffSaveKey = part.id,
498 isError = part.state is ToolError,
499 )
500 }
501 }
504/**
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.
509 */
510@Composable
511private fun CollapsibleDetail(
512 label: String?,
513 detail: String,
514 isDiff: Boolean,
515 keySuffix: String,
516 expanded: Boolean = false,
517 onToggleExpand: (() -> Unit)? = null,
518 moreLines: Int = 0,
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,
527) {
528 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
529 if (label != null) {
530 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(defaultExpanded) }
531 Row(
532 modifier = Modifier
533 .fillMaxWidth()
534 .defaultMinSize(minHeight = 48.dp)
535 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
536 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
537 verticalAlignment = Alignment.CenterVertically,
538 ) {
539 Icon(
540 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
541 contentDescription = null,
542 modifier = Modifier.size(16.dp),
543 tint = MaterialTheme.colorScheme.onSurfaceVariant,
544 )
545 Text(
546 label,
547 style = MaterialTheme.typography.labelSmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549 modifier = Modifier.padding(start = 4.dp),
550 )
551 }
552 if (!inputExpanded) return@Column
553 }
554 if (isDiff) {
555 DiffView(detail, saveKey = diffSaveKey)
556 } else {
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
565 Surface(
566 color = bgColor,
567 shape = MaterialTheme.shapes.small,
568 modifier = Modifier.fillMaxWidth(),
569 ) {
570 SelectionContainer {
571 Text(
572 detail,
573 style = MaterialTheme.typography.bodySmall,
574 fontFamily = FontFamily.Monospace,
575 color = textColor,
576 softWrap = false,
577 modifier = Modifier
578 .padding(8.dp)
579 .horizontalScroll(rememberScrollState()),
580 )
581 }
582 }
583 }
584 if (onToggleExpand != null) {
585 TextButton(
586 onClick = onToggleExpand,
587 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
588 modifier = Modifier
589 .defaultMinSize(minHeight = 48.dp)
590 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
591 ) {
592 Icon(
593 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
594 contentDescription = null,
595 modifier = Modifier.size(16.dp),
596 )
597 Text(
598 when {
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)
602 },
603 style = MaterialTheme.typography.labelSmall,
604 modifier = Modifier.padding(start = 4.dp),
605 )
606 }
607 }
608 if (onCopy != null) {
609 TextButton(
610 onClick = onCopy,
611 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
612 modifier = Modifier
613 .defaultMinSize(minHeight = 48.dp)
614 .semantics(mergeDescendants = true) {},
615 ) {
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))
618 }
619 }
620 }
623@Composable
624private fun ToolStatusIcon(state: ToolState) {
625 when (state) {
626 is ToolPending -> {
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)
630 Icon(
631 Icons.Filled.Schedule,
632 contentDescription = label,
633 modifier = Modifier.size(16.dp),
634 tint = MaterialTheme.colorScheme.onSurfaceVariant,
635 )
636 }
637 is ToolRunning -> {
638 val label = stringResource(R.string.tool_running)
639 CircularProgressIndicator(
640 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
641 strokeWidth = 2.dp,
642 )
643 }
644 is ToolCompleted ->
645 Icon(
646 Icons.Filled.CheckCircle,
647 contentDescription = stringResource(R.string.tool_completed),
648 modifier = Modifier.size(16.dp),
649 tint = MaterialTheme.colorScheme.primary,
650 )
651 is ToolError ->
652 Icon(
653 Icons.Filled.Error,
654 contentDescription = stringResource(R.string.tool_error),
655 modifier = Modifier.size(16.dp),
656 tint = MaterialTheme.colorScheme.error,
657 )
658 is ToolUnknown ->
659 Icon(
660 Icons.Filled.Bolt,
661 contentDescription = stringResource(R.string.tool_unknown),
662 modifier = Modifier.size(16.dp),
663 )
664 }
667@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
668@Composable
669private fun FileChip(
670 part: FilePart,
671 modifier: Modifier,
672 onOpenFile: ((String) -> Unit)?,
673 isImageUnavailable: Boolean = false,
674) {
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
692 canOpen -> openLabel
693 else -> copyLabel
694 }
695 val displayName = part.filename ?: part.url ?: stringResource(R.string.file)
696 Row(
697 modifier = modifier
698 .clip(MaterialTheme.shapes.small)
699 .background(MaterialTheme.colorScheme.surfaceVariant)
700 .combinedClickable(
701 role = Role.Button,
702 onClick = {
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))
710 }
711 },
712 onLongClick = {
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))
717 }
718 },
719 )
720 .semantics { contentDescription = semanticsLabel }
721 .defaultMinSize(minHeight = 48.dp)
722 .padding(horizontal = 12.dp, vertical = 6.dp),
723 verticalAlignment = Alignment.CenterVertically,
724 ) {
725 Icon(
726 Icons.Filled.Description,
727 contentDescription = null,
728 modifier = Modifier.size(16.dp),
729 tint = MaterialTheme.colorScheme.onSurfaceVariant,
730 )
731 Text(
732 if (isImageUnavailable) imageUnavailableLabel else displayName,
733 style = MaterialTheme.typography.bodyMedium,
734 modifier = Modifier.padding(start = 6.dp),
735 )
736 }
739/**
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).
746 *
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.
750 */
751@Composable
752private fun StepFinishSummary(part: StepFinishPart) {
753 val cost = part.cost
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) {
760 buildList {
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(" • ")
764 } ?: return
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) }
768 Row(
769 modifier = Modifier
770 .fillMaxWidth()
771 .defaultMinSize(minHeight = 48.dp)
772 .clickable(role = Role.Button) { expanded = !expanded }
773 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
774 verticalAlignment = Alignment.CenterVertically,
775 ) {
776 Icon(
777 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
778 contentDescription = null,
779 modifier = Modifier.size(14.dp),
780 tint = MaterialTheme.colorScheme.onSurfaceVariant,
781 )
782 if (expanded) {
783 Text(
784 summary,
785 style = MaterialTheme.typography.labelSmall,
786 color = MaterialTheme.colorScheme.onSurfaceVariant,
787 modifier = Modifier.padding(start = 4.dp, top = 2.dp),
788 )
789 } else {
790 Text(
791 stringResource(R.string.step_summary_collapsed),
792 style = MaterialTheme.typography.labelSmall,
793 color = MaterialTheme.colorScheme.onSurfaceVariant,
794 modifier = Modifier.padding(start = 4.dp),
795 )
796 }
797 }