Colour what a shell printed, swipe back, and let a stopped session take a setting
Bash output arrived with its escape sequences in it, so a coloured diff or test run was line noise around the thing being read. The sequences that decide how text looks are spans now and every other one is dropped, with a carriage return honoured the way a terminal honours it so a progress bar shows its final state rather than every state it passed through. A rightward drag anywhere on a session, spawn or settings screen steps back, following the finger so it can be abandoned. It loses every argument: a child that consumes horizontal drags -- a wide fence, a table, a selection -- has already taken the gesture before this sees it. Changing the model or the permission mode of a session with nothing running was refused, in words about the driver, while the config had already taken the value that its next start will use. Both now announce the stored setting instead, through one function, since which of the pair it is does not change the rule. The model-switch warning no longer fires after a clear: the server reports the context as unmeasured rather than zero afterwards, and the fallback reading counted the whole conversation still on screen. An image loading shows a spinner in the space it is about to fill, in the transcript and in the composer's attachments alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8dcd2cb708
commit
a383c19dd5
10 files changed
+651
-48
No files matched your search
@@ -0,0 +1,313 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* The sixteen colours a terminal program names, and the two it assumes.
|
||||
*
|
||||
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
|
||||
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
|
||||
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
|
||||
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
|
||||
* this list would silently move `ls`'s directory blue.
|
||||
*/
|
||||
data class AnsiPalette(
|
||||
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
|
||||
val colours: List<Color>,
|
||||
/** What uncoloured text is, needed only where a style has to state a colour. */
|
||||
val foreground: Color,
|
||||
/** What the text sits on, needed for reverse video. */
|
||||
val background: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a tool printed, with its terminal styling applied and everything else taken out.
|
||||
*
|
||||
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
|
||||
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
|
||||
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
|
||||
* of what a diff, a test run or a linter is saying.
|
||||
*
|
||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
|
||||
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
|
||||
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
|
||||
*
|
||||
* A carriage return is honoured the way a terminal honours it: what was written since the last line
|
||||
* break is thrown away and the line starts again. That is what makes a progress bar show its final
|
||||
* state rather than every state it passed through, which was tens of lines run together.
|
||||
*
|
||||
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
|
||||
* parsed rather than re-run on every recomposition of the card holding it.
|
||||
*/
|
||||
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
|
||||
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
|
||||
|
||||
val runs = mutableListOf<Run>()
|
||||
var sgr = Sgr.PLAIN
|
||||
var at = 0
|
||||
val plain = StringBuilder()
|
||||
|
||||
fun flush() {
|
||||
if (plain.isNotEmpty()) {
|
||||
runs.add(Run(plain.toString(), sgr.span(palette)))
|
||||
plain.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (at < text.length) {
|
||||
val c = text[at]
|
||||
when {
|
||||
c == ESC -> {
|
||||
flush()
|
||||
at =
|
||||
skipEscape(text, at) { params, final ->
|
||||
if (final == 'm') sgr = sgr.apply(params, palette)
|
||||
}
|
||||
}
|
||||
// A bare carriage return rewrites the line; one before a newline is the other half of
|
||||
// a Windows line ending and has nothing to rewrite.
|
||||
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
|
||||
flush()
|
||||
dropLine(runs)
|
||||
at++
|
||||
}
|
||||
c == '\r' -> at++
|
||||
// Everything printable, plus the two control characters that are layout rather than
|
||||
// terminal commands. A stray bell or backspace goes for the same reason a cursor
|
||||
// move does.
|
||||
c >= ' ' || c == '\n' || c == '\t' -> {
|
||||
plain.append(c)
|
||||
at++
|
||||
}
|
||||
else -> at++
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
return buildAnnotatedString {
|
||||
runs.forEach { run ->
|
||||
if (run.style == null) {
|
||||
append(run.text)
|
||||
} else {
|
||||
val pushed = pushStyle(run.style)
|
||||
append(run.text)
|
||||
pop(pushed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One stretch of text that shares a style. */
|
||||
private class Run(val text: String, val style: SpanStyle?)
|
||||
|
||||
/** Throws away everything written since the last line break, as a carriage return does. */
|
||||
private fun dropLine(runs: MutableList<Run>) {
|
||||
while (runs.isNotEmpty()) {
|
||||
val last = runs.removeAt(runs.size - 1)
|
||||
val breakAt = last.text.lastIndexOf('\n')
|
||||
if (breakAt >= 0) {
|
||||
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ESC = '\u001B'
|
||||
|
||||
private const val BELL = '\u0007'
|
||||
|
||||
/**
|
||||
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
|
||||
*
|
||||
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
|
||||
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
|
||||
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
|
||||
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
|
||||
*/
|
||||
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
|
||||
val next = text.getOrNull(at + 1) ?: return at + 1
|
||||
return when (next) {
|
||||
'[' -> {
|
||||
var end = at + 2
|
||||
while (end < text.length && text[end] !in CSI_FINAL) end++
|
||||
if (end >= text.length) {
|
||||
// Cut off mid-sequence, which is what a stream that has not finished arriving
|
||||
// looks like: drop the fragment rather than printing it, and the whole sequence
|
||||
// arrives with the next delta.
|
||||
text.length
|
||||
} else {
|
||||
onCsi(text.substring(at + 2, end), text[end])
|
||||
end + 1
|
||||
}
|
||||
}
|
||||
']',
|
||||
'P',
|
||||
'X',
|
||||
'^',
|
||||
'_' -> {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
|
||||
var end = at + 2
|
||||
while (end < text.length) {
|
||||
if (text[end] == BELL) return end + 1
|
||||
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
|
||||
end++
|
||||
}
|
||||
text.length
|
||||
}
|
||||
else -> at + 2
|
||||
}
|
||||
}
|
||||
|
||||
/** The bytes that end a CSI sequence. */
|
||||
private val CSI_FINAL = '@'..'~'
|
||||
|
||||
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
|
||||
private data class Sgr(
|
||||
val fg: Color?,
|
||||
val bg: Color?,
|
||||
val bold: Boolean,
|
||||
val dim: Boolean,
|
||||
val italic: Boolean,
|
||||
val underline: Boolean,
|
||||
val strike: Boolean,
|
||||
val reverse: Boolean,
|
||||
) {
|
||||
/** Null while nothing is set, so unstyled output costs no spans at all. */
|
||||
fun span(palette: AnsiPalette): SpanStyle? {
|
||||
if (this == PLAIN) return null
|
||||
val front = if (reverse) bg ?: palette.background else fg
|
||||
val back = if (reverse) fg ?: palette.foreground else bg
|
||||
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
|
||||
val stated = front ?: palette.foreground.takeIf { dim }
|
||||
return SpanStyle(
|
||||
color =
|
||||
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
|
||||
background = back ?: Color.Unspecified,
|
||||
fontWeight = if (bold) FontWeight.Bold else null,
|
||||
fontStyle = if (italic) FontStyle.Italic else null,
|
||||
textDecoration =
|
||||
when {
|
||||
underline && strike ->
|
||||
TextDecoration.combine(
|
||||
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
||||
)
|
||||
underline -> TextDecoration.Underline
|
||||
strike -> TextDecoration.LineThrough
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
|
||||
*
|
||||
* A code this does not model is ignored rather than reset from: the program meant something by
|
||||
* it, and starting again would also drop the codes beside it that are understood.
|
||||
*/
|
||||
fun apply(params: String, palette: AnsiPalette): Sgr {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
|
||||
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
|
||||
var state = this
|
||||
var at = 0
|
||||
while (at < codes.size) {
|
||||
val code = codes[at]
|
||||
state =
|
||||
when (code) {
|
||||
0 -> PLAIN
|
||||
1 -> state.copy(bold = true)
|
||||
2 -> state.copy(dim = true)
|
||||
3 -> state.copy(italic = true)
|
||||
4 -> state.copy(underline = true)
|
||||
7 -> state.copy(reverse = true)
|
||||
9 -> state.copy(strike = true)
|
||||
21,
|
||||
22 -> state.copy(bold = false, dim = false)
|
||||
23 -> state.copy(italic = false)
|
||||
24 -> state.copy(underline = false)
|
||||
27 -> state.copy(reverse = false)
|
||||
29 -> state.copy(strike = false)
|
||||
in 30..37 -> state.copy(fg = palette.colours[code - 30])
|
||||
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
|
||||
in 40..47 -> state.copy(bg = palette.colours[code - 40])
|
||||
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
|
||||
39 -> state.copy(fg = null)
|
||||
49 -> state.copy(bg = null)
|
||||
38,
|
||||
48 -> {
|
||||
val (colour, last) = extendedColour(codes, at, palette)
|
||||
at = last
|
||||
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
at++
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
companion object {
|
||||
val PLAIN =
|
||||
Sgr(
|
||||
fg = null,
|
||||
bg = null,
|
||||
bold = false,
|
||||
dim = false,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strike = false,
|
||||
reverse = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
|
||||
private const val DIM_ALPHA = 0.65f
|
||||
|
||||
/**
|
||||
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
|
||||
*
|
||||
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
|
||||
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
|
||||
* the same red.
|
||||
*/
|
||||
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
|
||||
when (codes.getOrNull(at + 1)) {
|
||||
5 -> {
|
||||
val n = codes.getOrNull(at + 2)
|
||||
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
|
||||
}
|
||||
2 -> {
|
||||
val r = codes.getOrNull(at + 2)
|
||||
val g = codes.getOrNull(at + 3)
|
||||
val b = codes.getOrNull(at + 4)
|
||||
if (r == null || g == null || b == null) null to at + 1
|
||||
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
|
||||
}
|
||||
else -> null to at + 1
|
||||
}
|
||||
|
||||
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
|
||||
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
|
||||
when {
|
||||
n < 0 -> palette.foreground
|
||||
n < 16 -> palette.colours[n]
|
||||
n < 232 -> {
|
||||
val i = n - 16
|
||||
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||
}
|
||||
n < 256 -> {
|
||||
val grey = 8 + (n - 232) * 10
|
||||
Color(grey, grey, grey)
|
||||
}
|
||||
else -> palette.foreground
|
||||
}
|
||||
|
||||
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
|
||||
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
|
||||
Reference in new issue
Block a user