v1.8.0 - live thinking markers with effort level
Browse filesThe transcript drew nothing while the model was reasoning: Event.Thinking only fired once the whole block completed, so a high-effort turn looked frozen for as long as it thought.
- Engine watches content_block_start for thinking (earliest signal) and splits reasoning deltas from text deltas
- Turn.Thinking carries live/effort/startedAt/tokens; the marker appears the instant reasoning starts and ticks while it streams
- settleThinking() closes the marker on every turn-end path so it can never outlive its turn
- passes --effort to the CLI, with a picker beside the model picker
.gitattributes
CHANGED
|
@@ -37,3 +37,4 @@ app/src/main/assets/proot filter=lfs diff=lfs merge=lfs -text
|
|
| 37 |
Cinder-v1.7.7.apk filter=lfs diff=lfs merge=lfs -text
|
| 38 |
Cinder-v1.7.8.apk filter=lfs diff=lfs merge=lfs -text
|
| 39 |
Cinder-v1.7.9.apk filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 37 |
Cinder-v1.7.7.apk filter=lfs diff=lfs merge=lfs -text
|
| 38 |
Cinder-v1.7.8.apk filter=lfs diff=lfs merge=lfs -text
|
| 39 |
Cinder-v1.7.9.apk filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
Cinder-v1.8.0.apk filter=lfs diff=lfs merge=lfs -text
|
Cinder-v1.7.9.apk → Cinder-v1.8.0.apk
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f708cde7ab038db3e53ba391ad83ea6bd9d05f81c8c6b5a4e6845ea29471a46b
|
| 3 |
+
size 33151336
|
app/build.gradle.kts
CHANGED
|
@@ -24,8 +24,8 @@ android {
|
|
| 24 |
// writable data directory, which is exactly what the sandbox needs to do. Termux
|
| 25 |
// used the same approach. Sideload only — Play Store requires a current target.
|
| 26 |
targetSdk = 28
|
| 27 |
-
versionCode =
|
| 28 |
-
versionName = "1.
|
| 29 |
ndk { abiFilters += "arm64-v8a" }
|
| 30 |
externalNativeBuild { cmake { arguments += "-DANDROID_STL=none" } }
|
| 31 |
}
|
|
|
|
| 24 |
// writable data directory, which is exactly what the sandbox needs to do. Termux
|
| 25 |
// used the same approach. Sideload only — Play Store requires a current target.
|
| 26 |
targetSdk = 28
|
| 27 |
+
versionCode = 17
|
| 28 |
+
versionName = "1.8.0"
|
| 29 |
ndk { abiFilters += "arm64-v8a" }
|
| 30 |
externalNativeBuild { cmake { arguments += "-DANDROID_STL=none" } }
|
| 31 |
}
|
app/src/main/java/app/cinder/Engine.kt
CHANGED
|
@@ -73,6 +73,14 @@ sealed interface Event {
|
|
| 73 |
data class Message(val text: String) : Event
|
| 74 |
/** A thinking block. Its text is empty unless the CLI is set to summarize reasoning. */
|
| 75 |
data class Thinking(val text: String) : Event
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
/** A small chunk of streamed output text (from --include-partial-messages), for live counting. */
|
| 77 |
data class Delta(val text: String) : Event
|
| 78 |
/** The model called a tool: Read, Edit, Write, Bash, Glob, … */
|
|
@@ -112,6 +120,14 @@ class Engine(private val sandbox: Sandbox, private val scope: CoroutineScope) {
|
|
| 112 |
/** Model id handed to --model; empty means the CLI's own default. */
|
| 113 |
var model: String = "claude-sonnet-5"
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
/** When set, the next launch resumes that stored session instead of starting fresh. */
|
| 116 |
var resumeId: String? = null
|
| 117 |
|
|
@@ -138,6 +154,7 @@ class Engine(private val sandbox: Sandbox, private val scope: CoroutineScope) {
|
|
| 138 |
// outright when it detects root, and it adds nothing over the permission mode.
|
| 139 |
append("--permission-mode $permissionMode ")
|
| 140 |
if (model.isNotBlank()) append("--model $model ")
|
|
|
|
| 141 |
resumeId?.let { append("--resume $it ") }
|
| 142 |
// Without this the model assumes a normal Linux box and reaches for apt/brew/sudo-less
|
| 143 |
// apk, all of which fail here.
|
|
@@ -227,11 +244,27 @@ class Engine(private val sandbox: Sandbox, private val scope: CoroutineScope) {
|
|
| 227 |
// the complete assistant message that arrives at the end of the block.
|
| 228 |
"stream_event" -> {
|
| 229 |
val ev = json.optJSONObject("event") ?: return
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
}
|
| 237 |
|
|
|
|
| 73 |
data class Message(val text: String) : Event
|
| 74 |
/** A thinking block. Its text is empty unless the CLI is set to summarize reasoning. */
|
| 75 |
data class Thinking(val text: String) : Event
|
| 76 |
+
/**
|
| 77 |
+
* Reasoning has *started*. Fires on the content_block_start frame, which is the earliest
|
| 78 |
+
* point the CLI tells us anything — the completed [Thinking] block doesn't arrive until the
|
| 79 |
+
* model has finished reasoning, which on a long turn is tens of seconds of apparent freeze.
|
| 80 |
+
*/
|
| 81 |
+
data class ThinkingStart(val effort: String) : Event
|
| 82 |
+
/** A chunk of streamed *reasoning*, as it is produced. Drives the live marker. */
|
| 83 |
+
data class ThinkingDelta(val text: String) : Event
|
| 84 |
/** A small chunk of streamed output text (from --include-partial-messages), for live counting. */
|
| 85 |
data class Delta(val text: String) : Event
|
| 86 |
/** The model called a tool: Read, Edit, Write, Bash, Glob, … */
|
|
|
|
| 120 |
/** Model id handed to --model; empty means the CLI's own default. */
|
| 121 |
var model: String = "claude-sonnet-5"
|
| 122 |
|
| 123 |
+
/**
|
| 124 |
+
* Reasoning effort handed to --effort: low, medium, high, xhigh, max.
|
| 125 |
+
* Surfaced in the thinking marker so the user can see what they're waiting on — a max-effort
|
| 126 |
+
* turn legitimately takes a long time, and saying so is the difference between "working" and
|
| 127 |
+
* "hung".
|
| 128 |
+
*/
|
| 129 |
+
var effort: String = "medium"
|
| 130 |
+
|
| 131 |
/** When set, the next launch resumes that stored session instead of starting fresh. */
|
| 132 |
var resumeId: String? = null
|
| 133 |
|
|
|
|
| 154 |
// outright when it detects root, and it adds nothing over the permission mode.
|
| 155 |
append("--permission-mode $permissionMode ")
|
| 156 |
if (model.isNotBlank()) append("--model $model ")
|
| 157 |
+
if (effort.isNotBlank()) append("--effort $effort ")
|
| 158 |
resumeId?.let { append("--resume $it ") }
|
| 159 |
// Without this the model assumes a normal Linux box and reaches for apt/brew/sudo-less
|
| 160 |
// apk, all of which fail here.
|
|
|
|
| 244 |
// the complete assistant message that arrives at the end of the block.
|
| 245 |
"stream_event" -> {
|
| 246 |
val ev = json.optJSONObject("event") ?: return
|
| 247 |
+
when (ev.optString("type")) {
|
| 248 |
+
// The moment reasoning begins. Without this the UI has nothing to show until
|
| 249 |
+
// the whole thinking block lands, which is exactly the "looks stuck" window.
|
| 250 |
+
"content_block_start" -> {
|
| 251 |
+
val t = ev.optJSONObject("content_block")?.optString("type")
|
| 252 |
+
if (t == "thinking" || t == "redacted_thinking") {
|
| 253 |
+
onEvent(Event.ThinkingStart(effort))
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
"content_block_delta" -> {
|
| 257 |
+
val delta = ev.optJSONObject("delta")
|
| 258 |
+
// Reasoning and prose both stream as deltas but mean different things to
|
| 259 |
+
// the UI: one drives the thinking marker, the other the response counter.
|
| 260 |
+
val think = delta?.optString("thinking").orEmpty()
|
| 261 |
+
if (think.isNotEmpty()) {
|
| 262 |
+
onEvent(Event.ThinkingDelta(think))
|
| 263 |
+
} else {
|
| 264 |
+
val text = delta?.optString("text").orEmpty()
|
| 265 |
+
if (text.isNotEmpty()) onEvent(Event.Delta(text))
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
}
|
| 269 |
}
|
| 270 |
|
app/src/main/java/app/cinder/MainActivity.kt
CHANGED
|
@@ -218,6 +218,7 @@ private fun Header(vm: Session, tab: Tab, onMenu: () -> Unit, onTab: (Tab) -> Un
|
|
| 218 |
)
|
| 219 |
}
|
| 220 |
ModelPicker(vm)
|
|
|
|
| 221 |
}
|
| 222 |
Text(
|
| 223 |
vm.cwd.replace(Regex("^/data/(user/0|data)/[^/]+/files"), "~"),
|
|
@@ -289,6 +290,48 @@ private fun CleanWorkspaceDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
|
| 289 |
)
|
| 290 |
}
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
@Composable
|
| 293 |
private fun ModelPicker(vm: Session) {
|
| 294 |
var open by remember { mutableStateOf(false) }
|
|
@@ -447,12 +490,39 @@ private fun TurnRow(turn: Turn) {
|
|
| 447 |
Modifier.clip(RoundedCornerShape(3.dp)).clickable { open = !open }
|
| 448 |
else Modifier
|
| 449 |
) {
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
}
|
| 457 |
// Reasoning text only exists when the CLI is set to summarize it; otherwise the
|
| 458 |
// block arrives empty and the marker alone is the signal.
|
|
|
|
| 218 |
)
|
| 219 |
}
|
| 220 |
ModelPicker(vm)
|
| 221 |
+
EffortPicker(vm)
|
| 222 |
}
|
| 223 |
Text(
|
| 224 |
vm.cwd.replace(Regex("^/data/(user/0|data)/[^/]+/files"), "~"),
|
|
|
|
| 290 |
)
|
| 291 |
}
|
| 292 |
|
| 293 |
+
/**
|
| 294 |
+
* Reasoning effort. Sits next to the model picker because it changes what a turn costs in time
|
| 295 |
+
* the same way the model does — and because the thinking marker names it, so it needs to be
|
| 296 |
+
* something the user can actually see and change.
|
| 297 |
+
*/
|
| 298 |
+
@Composable
|
| 299 |
+
private fun EffortPicker(vm: Session) {
|
| 300 |
+
var open by remember { mutableStateOf(false) }
|
| 301 |
+
Box {
|
| 302 |
+
Surface(
|
| 303 |
+
color = Panel,
|
| 304 |
+
shape = RoundedCornerShape(4.dp),
|
| 305 |
+
border = androidx.compose.foundation.BorderStroke(1.dp, Dim.copy(alpha = .4f)),
|
| 306 |
+
onClick = { open = true }
|
| 307 |
+
) {
|
| 308 |
+
Text(
|
| 309 |
+
"✻ ${vm.effort} ▾",
|
| 310 |
+
color = Dim, fontFamily = mono, fontSize = 11.sp,
|
| 311 |
+
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
| 312 |
+
)
|
| 313 |
+
}
|
| 314 |
+
DropdownMenu(expanded = open, onDismissRequest = { open = false }, containerColor = Panel) {
|
| 315 |
+
Text(
|
| 316 |
+
"reasoning effort",
|
| 317 |
+
color = Dim, fontFamily = mono, fontSize = 9.sp,
|
| 318 |
+
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
| 319 |
+
)
|
| 320 |
+
vm.efforts.forEach { e ->
|
| 321 |
+
DropdownMenuItem(
|
| 322 |
+
text = {
|
| 323 |
+
Text(
|
| 324 |
+
e, fontFamily = mono, fontSize = 12.sp,
|
| 325 |
+
color = if (e == vm.effort) Orange else Text0
|
| 326 |
+
)
|
| 327 |
+
},
|
| 328 |
+
onClick = { vm.chooseEffort(e); open = false }
|
| 329 |
+
)
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
@Composable
|
| 336 |
private fun ModelPicker(vm: Session) {
|
| 337 |
var open by remember { mutableStateOf(false) }
|
|
|
|
| 490 |
Modifier.clip(RoundedCornerShape(3.dp)).clickable { open = !open }
|
| 491 |
else Modifier
|
| 492 |
) {
|
| 493 |
+
// While reasoning is live the marker has to prove the app is alive: the star
|
| 494 |
+
// cycles and the clock ticks. A frozen "thinking…" is indistinguishable from
|
| 495 |
+
// a hang, which is the entire reason this row exists.
|
| 496 |
+
var tick by remember(turn.startedAt) { mutableStateOf(0) }
|
| 497 |
+
LaunchedEffect(turn.live, turn.startedAt) {
|
| 498 |
+
while (turn.live) {
|
| 499 |
+
kotlinx.coroutines.delay(250)
|
| 500 |
+
tick++
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
val star = if (turn.live) "✻✢✳✶"[(tick / 2) % 4].toString() else "✻"
|
| 504 |
+
Text(star, color = if (turn.live) Orange else Dim, fontSize = 12.sp,
|
| 505 |
+
modifier = Modifier.padding(top = 2.dp))
|
| 506 |
+
|
| 507 |
+
val label = if (turn.live) {
|
| 508 |
+
val secs = ((System.currentTimeMillis() - turn.startedAt) / 1000).toInt()
|
| 509 |
+
buildString {
|
| 510 |
+
append("thinking")
|
| 511 |
+
if (turn.effort.isNotBlank()) append(" (${turn.effort})")
|
| 512 |
+
append(" · ${secs}s")
|
| 513 |
+
if (turn.tokens > 0) {
|
| 514 |
+
append(" · ")
|
| 515 |
+
append(
|
| 516 |
+
if (turn.tokens >= 1000) "%.1fk".format(turn.tokens / 1000f)
|
| 517 |
+
else turn.tokens.toString()
|
| 518 |
+
)
|
| 519 |
+
append(" tokens")
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
} else if (turn.text.isBlank()) "thought"
|
| 523 |
+
else if (open) "thinking" else "thinking · tap to read"
|
| 524 |
+
|
| 525 |
+
Text(label, color = Dim, fontFamily = mono, fontSize = 11.sp)
|
| 526 |
}
|
| 527 |
// Reasoning text only exists when the CLI is set to summarize it; otherwise the
|
| 528 |
// block arrives empty and the marker alone is the signal.
|
app/src/main/java/app/cinder/Session.kt
CHANGED
|
@@ -48,7 +48,18 @@ sealed interface Turn {
|
|
| 48 |
val added: Int? = null,
|
| 49 |
val removed: Int? = null
|
| 50 |
) : Turn
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
data class Notice(val text: String, val isError: Boolean = false) : Turn
|
| 53 |
data class Link(val url: String, val label: String) : Turn
|
| 54 |
data class Done(val summary: String) : Turn
|
|
@@ -484,6 +495,18 @@ class Session(app: Application) : AndroidViewModel(app) {
|
|
| 484 |
transcript.add(Turn.Notice("resumed session ${record.id.take(8)}"))
|
| 485 |
}
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
private var pendingSend: kotlinx.coroutines.Job? = null
|
| 488 |
|
| 489 |
/**
|
|
@@ -501,6 +524,7 @@ class Session(app: Application) : AndroidViewModel(app) {
|
|
| 501 |
pendingSend?.cancel() // a first message still waiting on the toolchain install
|
| 502 |
pendingSend = null
|
| 503 |
engine.stop()
|
|
|
|
| 504 |
busy = false
|
| 505 |
responding = false
|
| 506 |
status = "stopped"
|
|
@@ -605,6 +629,37 @@ class Session(app: Application) : AndroidViewModel(app) {
|
|
| 605 |
responseTokens = responseChars / 4
|
| 606 |
}
|
| 607 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
is Event.Thinking -> {
|
| 609 |
if (!busy) busy = true
|
| 610 |
awaitingFreshTurn = false
|
|
@@ -613,9 +668,16 @@ class Session(app: Application) : AndroidViewModel(app) {
|
|
| 613 |
status = "working"
|
| 614 |
// Consecutive thinking blocks are one stretch of reasoning, not several.
|
| 615 |
val last = transcript.lastOrNull()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
if (last is Turn.Thinking && event.text.isBlank()) return@launch
|
| 617 |
if (last is Turn.Thinking && last.text.isNotBlank()) {
|
| 618 |
-
transcript[transcript.lastIndex] =
|
| 619 |
} else {
|
| 620 |
transcript.add(Turn.Thinking(event.text))
|
| 621 |
}
|
|
@@ -694,6 +756,7 @@ class Session(app: Application) : AndroidViewModel(app) {
|
|
| 694 |
awaitingFreshTurn = false
|
| 695 |
return@launch
|
| 696 |
}
|
|
|
|
| 697 |
busy = false
|
| 698 |
responding = false
|
| 699 |
status = "idle"
|
|
@@ -1446,6 +1509,21 @@ class Session(app: Application) : AndroidViewModel(app) {
|
|
| 1446 |
var modelsLive by mutableStateOf(false)
|
| 1447 |
private set
|
| 1448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
fun chooseModel(m: String) {
|
| 1450 |
if (m == model) return
|
| 1451 |
model = m
|
|
|
|
| 48 |
val added: Int? = null,
|
| 49 |
val removed: Int? = null
|
| 50 |
) : Turn
|
| 51 |
+
/**
|
| 52 |
+
* A stretch of reasoning. [live] is true while it's still being produced, which is when the
|
| 53 |
+
* marker needs to prove the app isn't hung: [startedAt] drives an elapsed clock and [tokens]
|
| 54 |
+
* an approximate count of reasoning produced so far.
|
| 55 |
+
*/
|
| 56 |
+
data class Thinking(
|
| 57 |
+
val text: String,
|
| 58 |
+
val live: Boolean = false,
|
| 59 |
+
val effort: String = "",
|
| 60 |
+
val startedAt: Long = 0L,
|
| 61 |
+
val tokens: Int = 0
|
| 62 |
+
) : Turn
|
| 63 |
data class Notice(val text: String, val isError: Boolean = false) : Turn
|
| 64 |
data class Link(val url: String, val label: String) : Turn
|
| 65 |
data class Done(val summary: String) : Turn
|
|
|
|
| 495 |
transcript.add(Turn.Notice("resumed session ${record.id.take(8)}"))
|
| 496 |
}
|
| 497 |
|
| 498 |
+
/**
|
| 499 |
+
* Close out a live thinking marker. A turn can end — normally, by interrupt, or by the engine
|
| 500 |
+
* dying — without the completed reasoning block ever arriving, and a marker left in [live]
|
| 501 |
+
* state would tick its clock forever and read as a hang.
|
| 502 |
+
*/
|
| 503 |
+
private fun settleThinking() {
|
| 504 |
+
val last = transcript.lastOrNull()
|
| 505 |
+
if (last is Turn.Thinking && last.live) {
|
| 506 |
+
transcript[transcript.lastIndex] = last.copy(live = false)
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
private var pendingSend: kotlinx.coroutines.Job? = null
|
| 511 |
|
| 512 |
/**
|
|
|
|
| 524 |
pendingSend?.cancel() // a first message still waiting on the toolchain install
|
| 525 |
pendingSend = null
|
| 526 |
engine.stop()
|
| 527 |
+
settleThinking()
|
| 528 |
busy = false
|
| 529 |
responding = false
|
| 530 |
status = "stopped"
|
|
|
|
| 629 |
responseTokens = responseChars / 4
|
| 630 |
}
|
| 631 |
|
| 632 |
+
// Reasoning has begun. Put the marker up immediately — this is the whole point:
|
| 633 |
+
// the completed Thinking block can be a minute away on a high-effort turn, and until
|
| 634 |
+
// now nothing was drawn in that window at all.
|
| 635 |
+
is Event.ThinkingStart -> {
|
| 636 |
+
if (!busy) busy = true
|
| 637 |
+
awaitingFreshTurn = false
|
| 638 |
+
responding = true
|
| 639 |
+
status = "thinking"
|
| 640 |
+
val last = transcript.lastOrNull()
|
| 641 |
+
if (last is Turn.Thinking && last.live) return@launch
|
| 642 |
+
transcript.add(
|
| 643 |
+
Turn.Thinking(
|
| 644 |
+
text = "", live = true, effort = event.effort,
|
| 645 |
+
startedAt = System.currentTimeMillis(), tokens = 0
|
| 646 |
+
)
|
| 647 |
+
)
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
// Reasoning streaming in. Only the counter moves; the text itself stays hidden unless
|
| 651 |
+
// the CLI is summarising, in which case the finished block replaces this row.
|
| 652 |
+
is Event.ThinkingDelta -> {
|
| 653 |
+
if (!busy) busy = true
|
| 654 |
+
awaitingFreshTurn = false
|
| 655 |
+
responding = true
|
| 656 |
+
val last = transcript.lastOrNull()
|
| 657 |
+
if (last is Turn.Thinking && last.live) {
|
| 658 |
+
transcript[transcript.lastIndex] =
|
| 659 |
+
last.copy(tokens = last.tokens + (event.text.length / 4).coerceAtLeast(1))
|
| 660 |
+
}
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
is Event.Thinking -> {
|
| 664 |
if (!busy) busy = true
|
| 665 |
awaitingFreshTurn = false
|
|
|
|
| 668 |
status = "working"
|
| 669 |
// Consecutive thinking blocks are one stretch of reasoning, not several.
|
| 670 |
val last = transcript.lastOrNull()
|
| 671 |
+
// The live placeholder is now finished: settle it rather than stacking a second
|
| 672 |
+
// marker underneath it.
|
| 673 |
+
if (last is Turn.Thinking && last.live) {
|
| 674 |
+
transcript[transcript.lastIndex] =
|
| 675 |
+
last.copy(text = event.text, live = false)
|
| 676 |
+
return@launch
|
| 677 |
+
}
|
| 678 |
if (last is Turn.Thinking && event.text.isBlank()) return@launch
|
| 679 |
if (last is Turn.Thinking && last.text.isNotBlank()) {
|
| 680 |
+
transcript[transcript.lastIndex] = last.copy(text = last.text + "\n" + event.text)
|
| 681 |
} else {
|
| 682 |
transcript.add(Turn.Thinking(event.text))
|
| 683 |
}
|
|
|
|
| 756 |
awaitingFreshTurn = false
|
| 757 |
return@launch
|
| 758 |
}
|
| 759 |
+
settleThinking()
|
| 760 |
busy = false
|
| 761 |
responding = false
|
| 762 |
status = "idle"
|
|
|
|
| 1509 |
var modelsLive by mutableStateOf(false)
|
| 1510 |
private set
|
| 1511 |
|
| 1512 |
+
/** Effort levels the CLI accepts for --effort, cheapest first. */
|
| 1513 |
+
val efforts = listOf("low", "medium", "high", "xhigh", "max")
|
| 1514 |
+
|
| 1515 |
+
/** Current reasoning effort; shown live in the thinking marker. */
|
| 1516 |
+
var effort by mutableStateOf(engine.effort)
|
| 1517 |
+
|
| 1518 |
+
fun chooseEffort(e: String) {
|
| 1519 |
+
if (e == effort) return
|
| 1520 |
+
effort = e
|
| 1521 |
+
engine.effort = e
|
| 1522 |
+
// --effort is read at launch like --model, so the running process can't be re-levelled.
|
| 1523 |
+
engine.stop()
|
| 1524 |
+
transcript.add(Turn.Notice("effort: $e (new session)"))
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
fun chooseModel(m: String) {
|
| 1528 |
if (m == model) return
|
| 1529 |
model = m
|