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// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
91// resilient decoding) so this stays human-readable; constructed lazily and memoized.
92private val prettyJson: Json = Json { prettyPrint = true }
94/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
95private fun ToolState.titleText(): String? = when (this) {
96 is ToolRunning -> title
97 is ToolCompleted -> title
101/** The elapsed wall-clock seconds a tool state ran for, when its time window is known.
102 * Returns null while a tool is still pending or when the server didn't report timing. */
103private fun ToolState.durationMs(): Long? {
104 val time = when (this) {
105 is ToolRunning -> time
106 is ToolCompleted -> time
110 val start = time.start ?: return null
111 val end = time.end ?: return null
112 return (end - start).coerceAtLeast(0L)
115/** Compact human-readable duration for a tool call (e.g. "2.3s", "1m 04s"). Sub-second
116 * runs render in milliseconds so a quick read still shows something distinguishable. */
117fun formatToolDuration(ms: Long): String {
118 if (ms < 1_000) return "${ms}ms"
119 val totalSeconds = ms / 1_000
120 val tenths = (ms % 1_000) / 100
121 if (totalSeconds < 60) {
122 return if (tenths == 0L) "${totalSeconds}s" else "${totalSeconds}.${tenths}s"
124 val minutes = totalSeconds / 60
125 val seconds = totalSeconds % 60
126 return if (minutes < 60) {
127 "${minutes}m ${seconds.toString().padStart(2, '0')}s"
129 val hours = minutes / 60
130 val mins = minutes % 60
131 "${hours}h ${mins.toString().padStart(2, '0')}m"
136 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
137 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
139 * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
140 * source path the viewer can open; null leaves the chip as a copy-path affordance
141 * (used when no file navigation is wired, e.g. in tests).
146 modifier: Modifier = Modifier,
147 isRunning: Boolean = false,
148 imageContext: ImageLoadContext? = null,
149 onOpenFile: ((path: String) -> Unit)? = null,
152 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
153 // A synthetic TextPart is server-generated content (e.g. a compacted-conversation
154 // summary) standing in for real exchanged messages. Mark it with a small muted
155 // chip above the body so a reader can tell it isn't a verbatim assistant reply —
156 // important when reviewing a compacted history where the "summary" framing would
157 // otherwise be indistinguishable from the agent speaking in its own voice.
158 if (part.synthetic) {
160 modifier = modifier.fillMaxWidth(),
161 verticalAlignment = Alignment.CenterVertically,
164 stringResource(R.string.synthetic_chip),
165 style = MaterialTheme.typography.labelSmall,
166 color = MaterialTheme.colorScheme.onSurfaceVariant,
168 .clip(MaterialTheme.shapes.extraSmall)
169 .background(MaterialTheme.colorScheme.surfaceVariant)
170 .padding(horizontal = 6.dp, vertical = 2.dp),
174 MarkdownText(part.text, modifier = modifier, streaming = isRunning, imageContext = imageContext)
176 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
177 is ToolPart -> ToolCallView(part, modifier)
178 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
179 RemoteImage(part, imageContext, modifier)
181 FileChip(part, modifier, onOpenFile)
183 is StepStartPart -> {} // boundary marker — nothing to draw
184 is StepFinishPart -> {} // metrics handled at message level
185 is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
186 is UnknownPart -> UnknownPartNote(modifier)
191private fun UnknownPartNote(modifier: Modifier) {
192 // Forward-compat: a part type the client doesn't model. Render a muted note (matching
193 // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
194 // the part vanishing silently — important when a new server release adds a part type.
196 modifier = modifier.fillMaxWidth(),
197 verticalAlignment = Alignment.CenterVertically,
200 Icons.AutoMirrored.Filled.HelpOutline,
201 contentDescription = null,
202 modifier = Modifier.size(14.dp),
203 tint = MaterialTheme.colorScheme.onSurfaceVariant,
206 stringResource(R.string.unknown_part),
207 style = MaterialTheme.typography.labelSmall,
208 color = MaterialTheme.colorScheme.onSurfaceVariant,
209 modifier = Modifier.padding(start = 6.dp),
215private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
216 if (text.isBlank()) return
217 val context = LocalContext.current
218 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
219 // inherit the positional saveable key of a prior block. A content-prefix key would
220 // change on nearly every token while streaming and snap the block back to collapsed;
221 // when the part has no id, use a constant — the key(part.id, index) wrapper at the
222 // call site already disambiguates sibling parts positionally, so a constant per
223 // block type won't collide.
224 val saveableKey = keyId.ifBlank { "reasoning" }
225 val haptics = LocalHapticFeedback.current
226 // While the reasoning is still streaming, default to expanded so live "Thinking…" is
227 // visible; once complete it collapses. A non-null override records an explicit user
228 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
229 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
230 // Sticky expand: once a block has streamed this session, pin it open after streaming ends
231 // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
232 // stream finishes. A historical block that never streamed this session (streaming is false
233 // from the first composition) never sets streamedThisSession, so it stays collapsed.
234 var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
235 LaunchedEffect(streaming) {
236 if (streaming) streamedThisSession = true
237 else if (streamedThisSession && userOverride == null) userOverride = true
239 val expanded = userOverride ?: streaming
240 val expandedState = stringResource(R.string.state_expanded)
241 val collapsedState = stringResource(R.string.state_collapsed)
242 val thinkingLabel = stringResource(R.string.thinking)
243 val thoughtsLabel = stringResource(R.string.thoughts)
244 // Hoist the collapsed word count so it can drive both the visible "(N)" label and the
245 // row's a11y stateDescription. Previously the count rendered as a bare "(12)" that
246 // TalkBack read as "12" with no context; folding it into the stateDescription makes the
247 // hidden-content size reachable to screen-reader users.
248 val collapsedWordCount = remember(text) {
249 text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
251 // Resolve the readable "N words hidden" form once (in the composable body — the semantics
252 // lambda below is not a composable scope, so it can't call pluralStringResource itself).
253 val collapsedWordsLabel = pluralStringResource(
254 R.plurals.reasoning_word_count,
258 Column(modifier = modifier.fillMaxWidth()) {
262 .defaultMinSize(minHeight = 48.dp)
263 .clickable(role = Role.Button) { userOverride = !expanded }
264 .padding(vertical = 4.dp)
266 // When collapsed (and not streaming), append how much reasoning is hidden
267 // so the state reads e.g. "Collapsed, 42 words hidden" — giving a TalkBack
268 // user the same size signal the visible "(42)" gives a sighted user.
269 stateDescription = when {
270 expanded -> expandedState
271 streaming -> collapsedState
272 else -> "$collapsedState, $collapsedWordsLabel"
275 verticalAlignment = Alignment.CenterVertically,
278 CircularProgressIndicator(
279 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
281 color = MaterialTheme.colorScheme.onSurfaceVariant,
285 Icons.Filled.Psychology,
286 contentDescription = null,
287 modifier = Modifier.size(16.dp),
288 tint = MaterialTheme.colorScheme.onSurfaceVariant,
292 if (streaming) thinkingLabel else thoughtsLabel,
293 style = MaterialTheme.typography.labelMedium,
294 color = MaterialTheme.colorScheme.onSurfaceVariant,
295 modifier = Modifier.padding(start = 6.dp),
297 // When collapsed, show a muted word count so the user has a signal of how much
298 // content is hidden before deciding to expand. Skipped while streaming (the
299 // content is growing and the label already says "Thinking…").
300 if (!streaming && !expanded) {
302 "($collapsedWordCount)",
303 style = MaterialTheme.typography.labelSmall,
304 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
305 modifier = Modifier.padding(start = 4.dp),
309 val expandMotion = rememberVisibilityTransitions()
312 enter = expandMotion.enter,
313 exit = expandMotion.exit,
316 // Wrap in SelectionContainer so the user can select a portion of the
317 // reasoning (e.g. a single step) instead of the all-or-nothing copy
318 // button below. Matches the markdown text's selectability.
322 style = MaterialTheme.typography.bodySmall,
323 color = MaterialTheme.colorScheme.onSurfaceVariant,
328 // Copy affordance lives outside AnimatedVisibility so it's reachable when the
329 // block is collapsed — previously the copy button was inside the expanded content,
330 // leaving a collapsed reasoning block with no copy affordance at all.
333 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
334 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
336 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
338 .defaultMinSize(minHeight = 48.dp)
339 .semantics(mergeDescendants = true) {},
341 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
342 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
347@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
349private fun ToolCallView(part: ToolPart, modifier: Modifier) {
350 val context = LocalContext.current
351 val haptics = LocalHapticFeedback.current
355 .clip(MaterialTheme.shapes.small)
356 .background(MaterialTheme.colorScheme.surfaceVariant)
359 Row(verticalAlignment = Alignment.CenterVertically) {
360 ToolStatusIcon(part.state)
363 style = MaterialTheme.typography.labelLarge,
364 fontFamily = FontFamily.Monospace,
365 modifier = Modifier.padding(start = 6.dp),
367 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
368 // reports both start and end. Omitted while still running (no end yet) and when
369 // timing wasn't reported — a bare "· 0ms" would be noise. Placed inline with the
370 // tool name so a glance at the row tells both *what* and *how long*.
371 part.state.durationMs()?.let { ms ->
373 stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
374 style = MaterialTheme.typography.labelSmall,
375 color = MaterialTheme.colorScheme.onSurfaceVariant,
376 modifier = Modifier.padding(start = 6.dp),
380 // A human-readable summary of what the tool was asked to do (e.g. "Reading
381 // src/main.kt"), when the server provides one. Sits directly under the tool
382 // name so the user understands the call before diving into input/output.
383 part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
386 style = MaterialTheme.typography.bodySmall,
387 color = MaterialTheme.colorScheme.onSurfaceVariant,
388 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
391 // A todowrite call carries the agent's task plan as its input; render it as a
392 // checklist rather than raw JSON so each step's progress reads at a glance.
393 val planTodos = remember(part) {
394 if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
396 if (planTodos.isNotEmpty()) {
397 TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
399 // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
400 // output so the call reads top-to-bottom: name → what → input → result.
401 part.state.inputElement()?.let { input ->
402 val inputLabel = stringResource(R.string.tool_input)
403 val pretty = remember(input) {
404 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
411 // The input block has a label row whose collapse toggle announces its state
412 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
413 // rather than an empty state (the defaults are "").
414 expandedState = stringResource(R.string.state_expanded),
415 collapsedState = stringResource(R.string.state_collapsed),
416 // Mirror the output block's copy affordance so a tool's arguments (e.g. a
417 // bash command or file-write content) can be copied for reuse without manual
420 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
421 copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
426 val detail = when (val s = part.state) {
427 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
428 is ToolError -> s.error ?: stringResource(R.string.error_generic)
431 if (detail != null) {
432 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
433 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
434 val isDiff = remember(detail) { looksLikeDiff(detail) }
435 val collapsed = remember(detail, isDiff) {
436 val head = detail.take(COLLAPSED_LIMIT)
437 // Truncating a diff mid-line makes DiffView render a malformed final line, so
438 // trim back to the last complete line when the content is a diff.
439 if (isDiff) head.substringBeforeLast('\n') else head
441 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
442 val expandedState = stringResource(R.string.state_expanded)
443 val collapsedState = stringResource(R.string.state_collapsed)
444 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
445 // How many lines are hidden while collapsed. detail.lines() splits the whole
446 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
447 // every recomposition, including each streaming update of a running tool.
448 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
449 // 0 (a single long line cut mid-way) falls back to the generic label.
450 val moreLines = remember(detail, collapsed) {
451 if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
457 keySuffix = "output",
458 expanded = expanded || detail.length <= COLLAPSED_LIMIT,
459 onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
460 { expanded = !expanded }
462 moreLines = moreLines,
463 expandedState = expandedState,
464 collapsedState = collapsedState,
466 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
467 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
469 diffSaveKey = part.id,
470 isError = part.state is ToolError,
477 * A collapsible monospace detail block shared by tool input and output. [label] is an
478 * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
479 * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
480 * with an expand/collapse affordance when [onToggleExpand] is supplied.
483private fun CollapsibleDetail(
488 expanded: Boolean = false,
489 onToggleExpand: (() -> Unit)? = null,
491 expandedState: String = "",
492 collapsedState: String = "",
493 onCopy: (() -> Unit)? = null,
494 diffSaveKey: String? = null,
495 isError: Boolean = false,
497 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
499 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
503 .defaultMinSize(minHeight = 48.dp)
504 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
505 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
506 verticalAlignment = Alignment.CenterVertically,
509 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
510 contentDescription = null,
511 modifier = Modifier.size(16.dp),
512 tint = MaterialTheme.colorScheme.onSurfaceVariant,
516 style = MaterialTheme.typography.labelSmall,
517 color = MaterialTheme.colorScheme.onSurfaceVariant,
518 modifier = Modifier.padding(start = 4.dp),
521 if (!inputExpanded) return@Column
524 DiffView(detail, saveKey = diffSaveKey)
526 // SelectionContainer so the user can select a portion of the output (e.g.
527 // a single line of stdout) instead of only copy-all via the button below.
528 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
529 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
530 // Error output is tinted with errorContainer so a glance conveys the tool failed,
531 // mirroring AgentErrorNote's styling for consistency.
532 val bgColor = if (isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant
533 val textColor = if (isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
536 shape = MaterialTheme.shapes.small,
537 modifier = Modifier.fillMaxWidth(),
542 style = MaterialTheme.typography.bodySmall,
543 fontFamily = FontFamily.Monospace,
548 .horizontalScroll(rememberScrollState()),
553 if (onToggleExpand != null) {
555 onClick = onToggleExpand,
556 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
558 .defaultMinSize(minHeight = 48.dp)
559 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
562 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
563 contentDescription = null,
564 modifier = Modifier.size(16.dp),
568 expanded -> stringResource(R.string.show_less)
569 moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
570 else -> stringResource(R.string.show_more)
572 style = MaterialTheme.typography.labelSmall,
573 modifier = Modifier.padding(start = 4.dp),
577 if (onCopy != null) {
580 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
582 .defaultMinSize(minHeight = 48.dp)
583 .semantics(mergeDescendants = true) {},
585 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
586 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
593private fun ToolStatusIcon(state: ToolState) {
596 // Queued, not yet executing: a static clock icon distinguishes it from an
597 // actively-running tool (spinner) so the user can tell what's waiting.
598 val label = stringResource(R.string.tool_queued)
600 Icons.Filled.Schedule,
601 contentDescription = label,
602 modifier = Modifier.size(16.dp),
603 tint = MaterialTheme.colorScheme.onSurfaceVariant,
607 val label = stringResource(R.string.tool_running)
608 CircularProgressIndicator(
609 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
615 Icons.Filled.CheckCircle,
616 contentDescription = stringResource(R.string.tool_completed),
617 modifier = Modifier.size(16.dp),
618 tint = MaterialTheme.colorScheme.primary,
623 contentDescription = stringResource(R.string.tool_error),
624 modifier = Modifier.size(16.dp),
625 tint = MaterialTheme.colorScheme.error,
630 contentDescription = stringResource(R.string.tool_unknown),
631 modifier = Modifier.size(16.dp),
636@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
638private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
639 val context = LocalContext.current
640 val haptics = LocalHapticFeedback.current
641 val path = part.sourcePath ?: part.url ?: part.filename
642 val copyLabel = stringResource(R.string.copy_path)
643 val openLabel = stringResource(R.string.open_file)
644 // When a source path is available and a navigation callback is wired, tapping the
645 // chip opens the file in the viewer (the action a user tapping a file reference
646 // most likely expects). Long-press still copies the path to the clipboard, so the
647 // copy affordance is preserved without being the default tap action. When no
648 // navigation is wired (e.g. tests), falls back to copy-path on tap.
649 val source = part.sourcePath
650 val opener = onOpenFile
651 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
652 val canOpen = openPath != null
653 val semanticsLabel = if (canOpen) openLabel else copyLabel
656 .clip(MaterialTheme.shapes.small)
657 .background(MaterialTheme.colorScheme.surfaceVariant)
661 val toOpen = openPath
662 if (toOpen != null) {
663 opener?.invoke(toOpen)
664 } else if (!path.isNullOrBlank()) {
665 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
666 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
667 showCopyToast(context, context.getString(R.string.path_copied))
671 if (!path.isNullOrBlank()) {
672 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
673 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
674 showCopyToast(context, context.getString(R.string.path_copied))
678 .semantics { contentDescription = semanticsLabel }
679 .defaultMinSize(minHeight = 48.dp)
680 .padding(horizontal = 12.dp, vertical = 6.dp),
681 verticalAlignment = Alignment.CenterVertically,
684 Icons.Filled.Description,
685 contentDescription = null,
686 modifier = Modifier.size(16.dp),
687 tint = MaterialTheme.colorScheme.onSurfaceVariant,
690 part.filename ?: part.url ?: stringResource(R.string.file),
691 style = MaterialTheme.typography.bodyMedium,
692 modifier = Modifier.padding(start = 6.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