Let a session choose how hard it thinks
Output is about an eighth of what a session costs and thinking is nearly all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests of this account's own transcripts -- so the level is the largest saving available short of shortening the conversation itself. Shaped like the working directory rather than like the model: the CLI's only two setting control requests are `set_model` and `set_permission_mode` (checked against the 2.1.258 binary), so `--effort` is read when the process launches and cannot be asked of a running one. `set_session_effort` records the level and stops the process; the next message or Start launches one that has it. That is also why the picker is in the session settings dialog beside Move, and not on the bar beside the model and the mode, which take effect mid-turn. `None` is a level in its own right -- the CLI's own default -- so the picker can return to it, and a blank is normalized to it at the boundary rather than stored as a level the CLI would reject. Offered only where it means something: `DriverKind::takes_effort` reports the capability and the phone leaves the row out entirely, rather than the session-type branch this app does not have anywhere else. A llama session would otherwise get a control whose only effect is stopping its process. Verified on the emulator against the sandbox's fake CLI: the picker sets it, the server reports it, and an echo session's dialog is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4f0935f98
commit
1ff662c7c3
8 files changed
+329
-6
No files matched your search
@@ -142,6 +142,20 @@ data class SessionSummary(
|
||||
val keepsOwnTranscript: Boolean,
|
||||
/** How much the session asks before acting; null when it was never set. */
|
||||
val permissionMode: String?,
|
||||
/**
|
||||
* How hard the model thinks, or null for the CLI's own default.
|
||||
*
|
||||
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
||||
* reported rather than assumed for the same reason [permissionMode] is.
|
||||
*/
|
||||
val effort: String?,
|
||||
/**
|
||||
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
|
||||
*
|
||||
* Asked of the server rather than worked out from the provider's name, because this is a
|
||||
* property of the driver's *kind* and the phone only has the name.
|
||||
*/
|
||||
val takesEffort: Boolean,
|
||||
/**
|
||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||
*/
|
||||
@@ -203,6 +217,8 @@ private fun parseSession(session: JSONObject) =
|
||||
title = session.getString("title"),
|
||||
model = session.optString("model").ifEmpty { null },
|
||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||
effort = session.optString("effort").ifEmpty { null },
|
||||
takesEffort = session.optBoolean("takesEffort", false),
|
||||
imported = session.optBoolean("imported", false),
|
||||
notify = session.optBoolean("notify", true),
|
||||
cwd = session.optString("cwd").ifEmpty { null },
|
||||
@@ -992,6 +1008,35 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
||||
*/
|
||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||
|
||||
/**
|
||||
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||
*
|
||||
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
||||
* not behave like them: the CLI has a control request for those two and none for this (checked
|
||||
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
||||
* the process, which is what the working directory beside it in this dialog does, and why it is
|
||||
* here rather than on a bar whose other controls take effect mid-turn.
|
||||
*/
|
||||
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
||||
|
||||
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
||||
const val DEFAULT_EFFORT = "default"
|
||||
|
||||
/**
|
||||
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
||||
* process is launched. The next message, or Start, runs one that has it.
|
||||
*
|
||||
* [level] is null for the CLI's own default.
|
||||
*/
|
||||
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/effort",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Switches how much a running session asks before acting, also in place. */
|
||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||
requestFromServer(
|
||||
|
||||
@@ -1846,6 +1846,8 @@ fun SessionScreen(
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
title = title,
|
||||
effort = summary.effort.takeIf { summary.takesEffort },
|
||||
takesEffort = summary.takesEffort,
|
||||
cachedBytes = cachedBytes,
|
||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||
// reads the same directory and would otherwise draw what is about to be deleted. The
|
||||
@@ -2249,7 +2251,7 @@ private const val ONE_TAP_MS = 250L
|
||||
* session is set to without spending a second line on saying it.
|
||||
*/
|
||||
@Composable
|
||||
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||
fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// When an outside touch last closed the menu.
|
||||
//
|
||||
|
||||
@@ -54,6 +54,16 @@ fun SessionSettingsDialog(
|
||||
*/
|
||||
title: String,
|
||||
onRenamed: (String) -> Unit,
|
||||
/**
|
||||
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
|
||||
*
|
||||
* Taken from the row this dialog was opened over rather than fetched, because unlike the
|
||||
* notification switch there is nothing else that changes it: the level is this app's to set and
|
||||
* the server does not resolve it into something else.
|
||||
*/
|
||||
effort: String?,
|
||||
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||
takesEffort: Boolean,
|
||||
/**
|
||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||
* the Reload row below, which is what would discard it.
|
||||
@@ -69,6 +79,8 @@ fun SessionSettingsDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
var level by remember(sessionId) { mutableStateOf(effort) }
|
||||
var effortError by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
||||
@@ -125,6 +137,26 @@ fun SessionSettingsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a thinking level, which ends the process the old level was launched with.
|
||||
*
|
||||
* Put back if the request is refused, for the reason the notification switch below gives: a
|
||||
* control that stays where it was put after a refusal is stating something untrue.
|
||||
*/
|
||||
fun setEffort(chosen: String?) {
|
||||
val was = level
|
||||
level = chosen
|
||||
effortError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||
} catch (e: ApiException) {
|
||||
level = was
|
||||
effortError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
||||
// and one that stays moved after a refusal lies.
|
||||
@@ -257,6 +289,44 @@ fun SessionSettingsDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Left out rather than disabled, the one place this dialog does that: a disabled
|
||||
// control teaches what the thing can do, and a llama session cannot do this at all
|
||||
// -- the row would be teaching something false about it.
|
||||
if (takesEffort) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Thinking", modifier = Modifier.weight(1f))
|
||||
PickerButton(
|
||||
current = level ?: DEFAULT_EFFORT,
|
||||
// The level the CLI picks for itself is in the list as well as in the
|
||||
// button, so leaving a level is not a one-way trip -- the same
|
||||
// correction the model picker carries.
|
||||
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||
onPick = { chosen ->
|
||||
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||
},
|
||||
)
|
||||
}
|
||||
// What it costs, said where it is about to be pressed, like Move above: the
|
||||
// CLI reads the level when it launches and has no control request for
|
||||
// changing one.
|
||||
Text(
|
||||
"Changing this stops the session's process. It starts again with the " +
|
||||
"next message, or with Start.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
effortError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
Reference in new issue
Block a user