SmallAICreator commited on
Commit
5a87537
·
verified ·
1 Parent(s): 986db01

v1.8.0 - live thinking markers with effort level

Browse files

The 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:dfa5f3e8a652e13f994847870498473d404a2e0fff8adb0e0041f40696db8790
3
- size 33134952
 
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 = 16
28
- versionName = "1.7.9"
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
- if (ev.optString("type") == "content_block_delta") {
231
- val delta = ev.optJSONObject("delta")
232
- val text = delta?.optString("text").orEmpty()
233
- .ifEmpty { delta?.optString("thinking").orEmpty() }
234
- if (text.isNotEmpty()) onEvent(Event.Delta(text))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Text("✻", color = Dim, fontSize = 12.sp, modifier = Modifier.padding(top = 2.dp))
451
- Text(
452
- if (turn.text.isBlank()) "thinking…"
453
- else if (open) "thinking" else "thinking · tap to read",
454
- color = Dim, fontFamily = mono, fontSize = 11.sp
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
- data class Thinking(val text: String) : Turn
 
 
 
 
 
 
 
 
 
 
 
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] = Turn.Thinking(last.text + "\n" + event.text)
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