Skip to content

Commit bfb5a7c

Browse files
sahrizviclaude
andauthored
feat(tui): toggle YOLO mode from the TUI with ctrl+y (#1078)
* feat(tui): toggle YOLO mode from the TUI with ctrl+y YOLO mode could only be turned on at startup, via `--yolo` or `ALTIMATE_CLI_YOLO`. Add a `ctrl+y` toggle so it can be switched on and off mid-session. - Enabling asks for confirmation; disabling is immediate, so turning a dangerous mode off is never harder than leaving it on. - Scoped to the current session, and to subagents spawned from it. A subagent runs in its own child session, so its permission requests carry the child id; lookups normalize to the root of the parent chain. - In-memory only. YOLO never survives a restart, so resuming an old session cannot silently put you back into it. - An explicit toggle beats the `--yolo` default in both directions, so a session started with `--yolo` can still be switched off. - The confirmation says what stops asking AND what stays blocked. Deny rules are refused by `Permission.ask` before any event reaches the TUI, so DROP DATABASE / DROP SCHEMA / TRUNCATE cannot be auto-approved by this mode. The footer indicator previously read `Flag.ALTIMATE_CLI_YOLO` directly. That is a `process.env` getter, not a signal, so it never re-rendered on change; it is now reactive and per-session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * fix(tui): address review findings on the YOLO ctrl+y toggle Security and correctness fixes from review of the initial implementation. Scoping (critical): a welcome-screen choice no longer acts as the fallback for every session without an explicit override. `session.new` navigates to home while the previous session keeps streaming, so pressing ctrl+y there would start auto-approving for that still-running session. The pending choice is now display-only until adopted, and adoption moved from a route effect to the session-creation path — the route effect also fired when resuming an existing conversation, silently enabling YOLO on it. Fail closed on an unresolvable session. Root resolution reads the TUI session store, which can lag a child's first permission request. Resolving an unhydrated child to itself missed the root's override, so a session the user had explicitly turned OFF could still auto-approve under `--yolo`. `resolveRoot` now returns undefined for an unknown session, an unknown ancestor, or an over-deep chain, and callers treat that as "do not approve". Auto-approve failures are no longer silent. The handler does not enqueue the request before replying, so a lost reply left the server-side Deferred unresolved and the agent hanging with nothing on screen. The generated SDK returns `{ error }` rather than throwing unless `throwOnError` is set, so the previous `.catch()` could never fire. Now opts in and falls back to the normal prompt. Enabling YOLO while a prompt is already on screen now clears it. Previously the most natural moment to press ctrl+y — blocked awaiting approval — showed "YOLO ON" beside a still-blocked agent. Also: re-check the route before applying a confirmation (quick-switch stays live during a modal), drop overrides on session.deleted, and say "this conversation and any subagents it spawns" rather than the contradictory "this session only" two lines above "applies to subagents too". Testing: the real-binary journeys are gated on OPENCODE_TEST_CLI, which CI deliberately does not set, so none of them gated the merge. Adds in-process sync tests covering auto-approve, reply failure, subagent inheritance, fail-closed, pending isolation and flush-on-enable. Fixing the harness to run those uncovered a missing ExitProvider in sync-fixture: every test in it failed with "Exit context must be used within a context provider". That one-line fix takes the TUI suite from 9 failures to 1 by repairing 8 pre-existing reds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * test(tui): pin ctrl+y behaviour when the console overlay has focus Review flagged that `ctrl+y` is already bound to `copy-selection` in the opentui console overlay (`consoleOptions` in app.tsx) and that the runtime behaviour was undefined and untested. A static keybind-config check cannot answer it — the console binding does not live in TuiKeybind.Definitions — so this drives the real binary and records what actually happens. Measured: with the console open, the yolo binding takes precedence and the confirmation dialog still appears. YOLO is never enabled without it, so the dangerous case (a bypass switched on by a keystroke aimed at the console) does not occur. Closing the console leaves the shortcut working normally. Pinned as assertions rather than logged, so changing the precedence has to be a deliberate decision. The practical cost is that console copy-selection is shadowed while this binding is registered — acceptable given the console ships unbound (`app_console: "none"`) and must be deliberately bound to open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * fix(tui): wrap yolo additions in altimate_change start/end markers Marker Guard failed in strict mode on four upstream-shared files. Each had a single-line `// altimate_change - ...` comment, which the guard does not count — it looks for a `altimate_change start` … `altimate_change end` block so the enclosed lines are protected when upstream changes are merged in. No behaviour change; comments only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * fix(tui): pass workspace on yolo auto-approve and stop duplicate replies Two defects introduced by the previous review round, found by CodeRabbit. The workspace was never sent on the flush path. `set()` took an optional workspace argument, but the app.tsx caller did not pass one, so replies from flushPendingPermissions went out with `workspace: undefined` while the session permission UI sends `project.workspace.current()` on every reply. Fixed at the source instead of at the call site — sync already has the project context, and threading the value through each caller is how it went missing in the first place. Replies could be sent twice for one request. flushPendingPermissions does not remove requests from the store; removal waits for the server's `permission.replied` event. Toggling yolo off and on again, or two set() calls, therefore replied to an already-settled id. The server rejects the second reply and autoApprove treated that rejection as a lost reply, putting a prompt back on screen for a request that was already answered. Now guarded by an in-flight set for concurrent duplicates plus optimistic removal on success for sequential ones — the event handler's removal stays idempotent. Adds a regression test for the double-enable path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * fix(tui): make the flush test assert its own claim, and stop y/n propagation Two remaining review points. The test named "enabling yolo clears a prompt that is already on screen" only asserted that a reply was sent — never that the prompt left the screen, which is the behaviour in its name. It now asserts the pending list empties. This works without emitting `permission.replied` because autoApprove removes the request optimistically on success. Adds `stopPropagation()` to the dialog's `y` and `n` branches, matching the `return` branch. Two reviewers disagreed on whether this is load-bearing: the dialog blurs the focused renderable on open and refocuses in a later tick, so a stray character cannot reach the prompt today. It is free consistency that keeps that true if the ordering ever changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * fix(tui): fail closed on cyclic chains, drop dead code, de-duplicate removal Follow-up review round. resolveRoot treated a cyclic parent chain as a valid root, returning whichever node the walk stopped on. That node could carry an override or inherit `--yolo`, so a malformed chain could auto-approve — the same fail-open shape the unknown-session guard already prevents. Cycles now return undefined. The unit test had encoded the old behaviour as expected; corrected, and extended to assert a cyclic chain never auto-approves. Reverts the change to routes/session/footer.tsx. That component is imported nowhere (its sibling subagent-footer.tsx is), so the indicator there can never render and the earlier claim about fixing its reactivity was wrong. The visible indicator users get is the chat-panel hint in component/prompt, which the journeys assert. Leaving the dead component untouched. Removes `sync.yolo.forget()` — added but never called; session cleanup already happens in the session.deleted handler. `permission.replied` now shares removePermission with the auto-approve path instead of repeating the search/splice inline. The double-enable regression test was passing vacuously: with an instant mock the store is cleared before the second set() runs, so the second flush was a no-op and the in-flight guard was never exercised. The reply is now held open so the second toggle lands while the first is genuinely in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU * fix(tui): wrap the shared permission removal in altimate_change markers Marker Guard strict mode again — the permission.replied handler now delegates to removePermission, and that replacement line needs start/end markers like the rest of the fork's edits to upstream-shared files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964Prd1Sz5JwWZmNTrFdiU --------- Co-authored-by: Haider <haider@altimate.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 092567a commit bfb5a7c

14 files changed

Lines changed: 1660 additions & 51 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// altimate_change — does ctrl+y collide with the opentui console's copy-selection?
2+
//
3+
// `app.tsx` configures the console overlay with
4+
// `consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }] }`,
5+
// so `ctrl+y` already means something while the console holds focus. Review flagged the
6+
// runtime behaviour as undefined and untested — a static keybind-config check cannot
7+
// answer it, because the console binding does not live in TuiKeybind.Definitions.
8+
//
9+
// This drives the real binary: open the console, press ctrl+y, and record what actually
10+
// happens. The assertion is deliberately written around whichever behaviour is real,
11+
// with the finding documented, rather than asserting a behaviour we would prefer.
12+
import { describe, expect, test } from "bun:test"
13+
import { booted, suiteEnabled, withJourney, type TmuxJourney } from "./harness"
14+
15+
const maybeDescribe = suiteEnabled() ? describe : describe.skip
16+
const journeyOptions = { timeout: 90_000, retry: 1 }
17+
18+
const CONFIRM = /Turn on YOLO mode for this session\?/i
19+
const ENABLED = /YOLO ON/i
20+
21+
// The console toggle ships unbound ("none"); bind it so the journey can open the overlay.
22+
const consoleEnv = {
23+
env: { ALTIMATE_CLI_YOLO: "false" },
24+
config: (base: Record<string, unknown>) => ({
25+
...base,
26+
keybinds: { ...((base.keybinds as Record<string, unknown>) ?? {}), app_console: "ctrl+o" },
27+
}),
28+
}
29+
30+
async function openConsole(tui: TmuxJourney) {
31+
tui.send("C-o")
32+
// The console overlay repaints the pane; give it a beat to appear.
33+
await Bun.sleep(1500)
34+
}
35+
36+
maybeDescribe("real-binary TUI journeys: yolo vs console focus", () => {
37+
test(
38+
"ctrl+y with the console open does not silently half-fire",
39+
async () => {
40+
await withJourney(
41+
"yolo console focus",
42+
async (tui) => {
43+
await booted(tui)
44+
const before = tui.snapshot()
45+
expect(before).not.toMatch(ENABLED)
46+
47+
await openConsole(tui)
48+
tui.send("C-y")
49+
await Bun.sleep(2000)
50+
const after = tui.snapshot()
51+
52+
// Measured behaviour: the yolo binding takes precedence over the console's
53+
// copy-selection, and the confirmation still gates the bypass. Pinned as an
54+
// assertion rather than logged, so a future precedence change has to be a
55+
// deliberate decision instead of a silent one.
56+
//
57+
// The dangerous state this rules out is `enabled && !dialogShown` — a
58+
// permission bypass switched on by a keystroke aimed at the console.
59+
expect(CONFIRM.test(after)).toBe(true)
60+
expect(ENABLED.test(after)).toBe(false)
61+
62+
// Escaping leaves no half-applied state.
63+
tui.send("Escape")
64+
await tui.waitFor((plain) => !CONFIRM.test(plain), 20_000)
65+
expect(tui.snapshot()).not.toMatch(ENABLED)
66+
},
67+
consoleEnv,
68+
)
69+
},
70+
journeyOptions,
71+
)
72+
73+
test(
74+
"ctrl+y still works normally after the console is closed again",
75+
async () => {
76+
// Guards the practical regression: whatever the console does with the key, the
77+
// shortcut must not be left broken once the overlay is dismissed.
78+
await withJourney(
79+
"yolo console restore",
80+
async (tui) => {
81+
await booted(tui)
82+
await openConsole(tui)
83+
tui.send("C-o") // close
84+
await Bun.sleep(1500)
85+
86+
tui.send("C-y")
87+
await tui.waitFor((plain) => CONFIRM.test(plain), 20_000)
88+
tui.send("y")
89+
await tui.waitFor((plain) => ENABLED.test(plain), 20_000)
90+
},
91+
consoleEnv,
92+
)
93+
},
94+
journeyOptions,
95+
)
96+
})
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// altimate_change — functional end-to-end coverage for YOLO mode.
2+
//
3+
// yolo.test.ts proves the UI states. This file proves the thing the UI is a
4+
// control for: that enabling yolo actually stops the agent asking, that disabling
5+
// restores asking, and that the guardrails the confirmation dialog promises are
6+
// real rather than aspirational.
7+
//
8+
// Assertions are on OBSERVABLE SIDE EFFECTS (did the command run? does the marker
9+
// file exist?) rather than on the permission dialog's text. journeys.test.ts
10+
// documents that the permission prompt does not render reliably under the mock-model
11+
// harness, so dialog-text assertions are flaky — but whether a tool actually EXECUTED
12+
// is unambiguous, and is the property that matters for a permission bypass.
13+
import { describe, expect, test } from "bun:test"
14+
import fs from "node:fs/promises"
15+
import path from "node:path"
16+
import { booted, submitPrompt, suiteEnabled, withJourney, type TmuxJourney } from "./harness"
17+
18+
const maybeDescribe = suiteEnabled() ? describe : describe.skip
19+
const journeyOptions = { timeout: 90_000, retry: 1 }
20+
21+
// bash must be "ask" for this suite to mean anything: the harness default is
22+
// `"*": "allow"`, under which nothing ever prompts and yolo would be a no-op.
23+
const askForBash = {
24+
env: { ALTIMATE_CLI_YOLO: "false" },
25+
config: (base: Record<string, unknown>) => {
26+
const agent = (base.agent ?? {}) as Record<string, Record<string, unknown>>
27+
return {
28+
...base,
29+
agent: {
30+
...agent,
31+
builder: {
32+
...agent.builder,
33+
permission: { "*": "allow", question: "allow", plan_enter: "allow", bash: "ask" },
34+
},
35+
},
36+
}
37+
},
38+
}
39+
40+
async function exists(file: string) {
41+
return await fs
42+
.stat(file)
43+
.then(() => true)
44+
.catch(() => false)
45+
}
46+
47+
async function enableYolo(tui: TmuxJourney) {
48+
tui.send("C-y")
49+
await tui.waitFor((plain) => /Turn on YOLO mode for this session\?/i.test(plain), 20_000)
50+
tui.send("y")
51+
await tui.waitFor((plain) => /YOLO ON/i.test(plain), 20_000)
52+
}
53+
54+
maybeDescribe("real-binary TUI journeys: yolo mode behaviour", () => {
55+
test(
56+
"yolo off: an ask-gated command does NOT run on its own",
57+
async () => {
58+
// Control case. If this ever starts passing trivially (the command runs with
59+
// yolo off), the suite below proves nothing and the permission gate has broken.
60+
await withJourney(
61+
"yolo effect control",
62+
async (tui, ctx) => {
63+
const marker = path.join(ctx.workspace, "ran-without-yolo.txt")
64+
await booted(tui)
65+
await tui.ctx.llm.tool("bash", {
66+
command: `touch ${JSON.stringify(marker)}`,
67+
description: "create marker",
68+
})
69+
await tui.ctx.llm.text("done")
70+
await submitPrompt(tui, "create the marker")
71+
await Bun.sleep(12_000)
72+
expect(await exists(marker)).toBe(false)
73+
},
74+
askForBash,
75+
)
76+
},
77+
journeyOptions,
78+
)
79+
80+
test(
81+
"yolo on: an ask-gated command runs without prompting",
82+
async () => {
83+
await withJourney(
84+
"yolo effect enabled",
85+
async (tui, ctx) => {
86+
const marker = path.join(ctx.workspace, "ran-with-yolo.txt")
87+
await booted(tui)
88+
await enableYolo(tui)
89+
await tui.ctx.llm.tool("bash", {
90+
command: `touch ${JSON.stringify(marker)}`,
91+
description: "create marker",
92+
})
93+
await tui.ctx.llm.text("done")
94+
await submitPrompt(tui, "create the marker")
95+
await tui.waitFor(async () => await exists(marker), 40_000)
96+
expect(await exists(marker)).toBe(true)
97+
},
98+
askForBash,
99+
)
100+
},
101+
journeyOptions,
102+
)
103+
104+
test(
105+
"guardrails hold under yolo: a denied DDL command never executes",
106+
async () => {
107+
// The claim printed in the confirmation dialog. DROP DATABASE is a hard "deny"
108+
// in agent.ts safetyDenials, and Permission.ask refuses deny matches before any
109+
// event reaches the TUI — so yolo must have no way to auto-approve it.
110+
//
111+
// The command is `DROP DATABASE x ; touch <marker>`: the whole string matches the
112+
// "DROP DATABASE *" deny pattern, so bash should never run. If the guardrail
113+
// leaked, bash WOULD run and the trailing touch would land the marker even though
114+
// the DDL itself is not a shell builtin. Marker present == guardrail bypassed.
115+
await withJourney(
116+
"yolo effect guardrail",
117+
async (tui, ctx) => {
118+
const marker = path.join(ctx.workspace, "ddl-bypassed.txt")
119+
await booted(tui)
120+
await enableYolo(tui)
121+
await tui.ctx.llm.tool("bash", {
122+
command: `DROP DATABASE prod ; touch ${JSON.stringify(marker)}`,
123+
description: "drop the database",
124+
})
125+
await tui.ctx.llm.text("done")
126+
await submitPrompt(tui, "drop the prod database")
127+
await Bun.sleep(15_000)
128+
expect(await exists(marker)).toBe(false)
129+
},
130+
askForBash,
131+
)
132+
},
133+
journeyOptions,
134+
)
135+
136+
test(
137+
"control for the guardrail test: the same semicolon-chained shape DOES run under yolo",
138+
async () => {
139+
// Without this, the guardrail test above is vacuous: a missing marker file would
140+
// be equally consistent with "the deny rule worked" and with "semicolon-chained
141+
// commands never execute under this harness at all". This runs the identical
142+
// shape with the DDL prefix replaced by a harmless echo, so the ONLY difference
143+
// between the two cases is the denied pattern.
144+
await withJourney(
145+
"yolo effect guardrail control",
146+
async (tui, ctx) => {
147+
const marker = path.join(ctx.workspace, "chained-ran.txt")
148+
await booted(tui)
149+
await enableYolo(tui)
150+
await tui.ctx.llm.tool("bash", {
151+
command: `echo prod ; touch ${JSON.stringify(marker)}`,
152+
description: "harmless chained command",
153+
})
154+
await tui.ctx.llm.text("done")
155+
await submitPrompt(tui, "run the harmless chained command")
156+
await tui.waitFor(async () => await exists(marker), 40_000)
157+
expect(await exists(marker)).toBe(true)
158+
},
159+
askForBash,
160+
)
161+
},
162+
journeyOptions,
163+
)
164+
165+
test(
166+
"turning yolo back off restores prompting",
167+
async () => {
168+
await withJourney(
169+
"yolo effect toggled back off",
170+
async (tui, ctx) => {
171+
const allowed = path.join(ctx.workspace, "while-on.txt")
172+
const blocked = path.join(ctx.workspace, "after-off.txt")
173+
await booted(tui)
174+
await enableYolo(tui)
175+
176+
await tui.ctx.llm.tool("bash", {
177+
command: `touch ${JSON.stringify(allowed)}`,
178+
description: "create marker",
179+
})
180+
await tui.ctx.llm.text("done")
181+
await submitPrompt(tui, "first marker")
182+
await tui.waitFor(async () => await exists(allowed), 40_000)
183+
184+
// Off again — no confirmation expected on the way out.
185+
tui.send("C-y")
186+
await tui.waitFor((plain) => !/YOLO ON/i.test(plain), 20_000)
187+
188+
await tui.ctx.llm.tool("bash", {
189+
command: `touch ${JSON.stringify(blocked)}`,
190+
description: "create marker",
191+
})
192+
await tui.ctx.llm.text("done")
193+
await submitPrompt(tui, "second marker")
194+
await Bun.sleep(12_000)
195+
expect(await exists(blocked)).toBe(false)
196+
},
197+
askForBash,
198+
)
199+
},
200+
journeyOptions,
201+
)
202+
})

0 commit comments

Comments
 (0)