169 lines
6.8 KiB
Kotlin
169 lines
6.8 KiB
Kotlin
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<String>,
|
|
) {
|
|
/** 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<String, Pair<String, Language?>> =
|
|
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),
|
|
)
|
|
}
|
|
}
|
|
}
|