package com.example.aiapp import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import org.json.JSONObject /** * A tool call's input, read rather than dumped. * * Every tool's input arrives as JSON, and showing it raw makes the reader parse * `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields * that carry the meaning are pulled out, and anything left over is still shown, because dropping a * field would be claiming the tool has no other input when it might. */ data class ToolInput( /** The thing that will actually be run or read, if this tool has one. */ val subject: String?, /** The language [subject] is written in, for highlighting. */ val language: Language?, /** The tool's own one-line summary, when it wrote one. */ val description: String?, /** * How long the call may take, in the largest units it fits. Shown apart because it is a limit * on the call rather than part of what the call does. */ val timeout: String?, /** Everything else, as `name: value` lines. Never dropped. */ val rest: List, ) { /** The one line to show when there is only room for one: what this call is for. */ val title: String? get() = description ?: subject } /** * Which field of which tool is the subject. * * A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them * from being the special case that gets its own code path. Unknown tools fall through to "no * subject, everything is rest". */ private val SUBJECTS: Map> = mapOf( "Bash" to ("command" to Language.SHELL), "Shell" to ("command" to Language.SHELL), "Patch" to ("diff" to Language.DIFF), "Read" to ("file_path" to null), "Write" to ("file_path" to null), "Edit" to ("file_path" to null), "Glob" to ("pattern" to null), "Grep" to ("pattern" to null), "WebFetch" to ("url" to null), "WebSearch" to ("query" to null), // Persisted transcripts keep the provider vocabulary they were written with. "web_search" to ("query" to null), ) /** Fields that are the tool's own prose about itself rather than input to it. */ private val DESCRIPTIONS = listOf("description", "prompt") fun parseToolInput(tool: String, input: String): ToolInput { val json = try { JSONObject(input) } catch (_: org.json.JSONException) { // Not an object: older transcripts and some tools send a bare string. It is still the // input, so it is still shown. return ToolInput( null, null, null, null, input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(), ) } val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) val subject = subjectKey ?.let { json.optString(it) } ?.takeIf { it.isNotBlank() } ?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it } val description = DESCRIPTIONS.firstNotNullOfOrNull { json.optString(it).takeIf { v -> v.isNotBlank() } } val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) } val rest = json .keys() .asSequence() .filter { it != subjectKey || subject == null } .filter { it !in DESCRIPTIONS || description == null } .filter { it != "timeout" || timeout == null } .sorted() .map { key -> "$key: ${json.get(key)}" } .toList() return ToolInput(subject, language, description, timeout, rest) } /** * Removes Codex's rendered Bash argv from old transcript rows. * * New events arrive normalized by the server, but persisted transcripts keep the input originally * written to them. Only the outer pair are presentation quoting: quotes inside the command belong * to the command and must not be parsed as an early end delimiter. */ internal fun renderedBashScript(command: String): String? { val prefix = listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull { command.startsWith(it) } ?: return null val quoted = command.removePrefix(prefix) return quoted .takeIf { it.length >= 2 && ((it.startsWith('\'') && it.endsWith('\'')) || (it.startsWith('"') && it.endsWith('"'))) } ?.substring(1, quoted.lastIndex) } /** * A tool call's input: its subject highlighted, then whatever else it carried. * * On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the * call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a * rendering fault. * * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs * with the reader's text rather than inside the machine's; [ToolCard] draws it above this. */ @Composable fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { val parsed = remember(tool, input) { parseToolInput(tool, input) } if (parsed.subject == null && parsed.rest.isEmpty()) return RawBlock(modifier) { parsed.subject?.let { subject -> // Not wrapped: a wrapped command hides where its arguments end, and the long one is the // one being read closely. Text( // Not cached: a tool's subject is one command line, which lexes in microseconds -- // the cache exists for a fence with two hundred lines in it. remember(subject, parsed.language) { highlight(subject, parsed.language) }, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, softWrap = false, modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) } parsed.rest.forEach { Text( it, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), ) } } }