Originally published on my own site.
I build a desktop app where an AI agent can run commands on your machine. Every
command goes through an approval dialog first: here is exactly what I am about
to run, yes or no.
Halfway through recording a demo, I got stuck. The buttons went off the
bottom of the screen and I could neither approve nor reject.
The conclusion first: showing the user everything and letting the user choose
are two different properties. Once they cannot choose, there is no approval
step — there is just a modal.
What happened
The model decided to write an HTML file, and built this:
cat > index.html <<'EOF'
<!doctype html>
… 4,558 bytes of HTML …
EOF
That whole string goes into showMessageBox's detail. The file contents
became the height of the approval dialog. It grew taller than the display and
the buttons ended up somewhere below the bottom edge.
detail does not scroll.
Escape worked. Enter did not.
Two things I found while stuck:
- Escape dismissed it
- Enter did nothing
Because I had set defaultId and cancelId both to 0 — the safe button. On
macOS, when those two point at the same index, the Return key equivalent is
dropped.
Defaulting to the safe button is the right call; it stops a stray Enter from
approving something dangerous. But that same setting removes the keyboard
path to approving at all. Decide one without looking at the other and this is
where you land.
Having Escape as the only escape hatch is a thin design.
The same recording broke in a second place
cd ~/work && claude "build me a landing page" went down the
wait-for-the-result path. Claude Code sat at its prompt until the 60-second
timeout killed it.
No terminal ever opened, so from the user's side nothing happened at all.
I confirmed it afterwards: not one hand-off script had been written to disk.
Interactive commands must never go down a path that waits for output.
Three layers of fix
1. Split file writing out of command execution
What the approver needs to see is different for the two operations.
For a command, you need the whole thing to judge what it does. For a write, what
you need is where, how many bytes, and is it an overwrite. The content
itself can be inspected separately if you want it.
Splitting the tool decoupled the dialog's size from the content's size.
| content written | dialog |
|---|---|
| 4,558 bytes of HTML | 16 lines |
| 34,889 bytes | 17 lines |
Three more decisions came with it:
-
Overwrites are their own case.
warningtype, and the words "this will be lost" -
Expand
~and relative paths before asking. Approve the unexpanded string and the path you approved can differ from the path that gets written - Both dialogs default to the "do not write" button
2. Refuse terminal-hogging commands before asking
claude, codex, gemini, vim, ssh, psql and 18 others. Check every
segment joined by &&, ||, ;, |.
export function findInteractiveCommand(command: string): string | null {
for (const segment of command.split(/&&|\|\||[;|]/)) {
const words = segment.trim().split(/\s+/)
.filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)); // skip FOO=1
const head = (words[0] ?? '').replace(/^.*\//, '').replace(/^["']|["']$/g, '');
if (INTERACTIVE_COMMANDS.includes(head)) return head;
}
return null;
}
Making someone click a button and then failing is bad manners. Refuse
before the dialog, and tell the model to use the hand-it-to-a-terminal tool
instead.
3. If it is still long, truncate the display
14 lines / 900 characters. Write the full text to a temp file and put that
path in the dialog.
Never truncate the thing being approved. Only the string being shown. Mix
those two up and you get the worst possible shape: the user approves something
short and something long runs.
How do you test "can still be clicked"?
You cannot eyeball it. What you can write down is that the size does not
depend on the content.
it('stays the same size regardless of content length', () => {
const short = planWrite('/t/a.html', '<p>hi</p>', '/', () => false);
const huge = planWrite('/t/a.html',
Array.from({ length: 2000 }, (_, i) => `<p>${i}</p>`).join('\n'), '/', () => false);
const a = String(buildWriteApproval(short, 'reason', 'en').options.detail);
const b = String(buildWriteApproval(huge, 'reason', 'en').options.detail);
expect(b.length).toBeLessThan(a.length + 200);
expect(b.length).toBeLessThan(900);
});
a.length + 200 is the real assertion; 900 is a belt-and-braces ceiling.
"The big one is about the same as the small one" encodes the entire reason
the tool was split.
For the interactive check, assert that no dialog was shown at all:
it('refuses before asking — no dialog at all', async () => {
const showMessageBox = vi.fn();
const executeCommand = vi.fn();
await runCommand('cd ~/x && claude "build it"', 'reason', { showMessageBox, executeCommand, … });
expect(showMessageBox).not.toHaveBeenCalled();
expect(executeCommand).not.toHaveBeenCalled();
});
A postscript: the display depended on how the argument was passed
While fixing the above I found one more. The "open a file" approval was
printing whatever string the model handed it.
First call: ~/Downloads/…. Second call: /Users/<my-username>/Downloads/….
Same file, different display.
If the display depends on how the argument was phrased, it is not an approval
screen. Now the path is resolved to an absolute path for opening, and only
the displayed string is folded back to ~.
Worse, the ~ form would not have opened at all — Electron's
shell.openPath does not expand it. Approving that dialog did nothing.
Takeaways
- Showing and choosing are different. No choice, no approval
- Matching
defaultIdandcancelIdkills Enter on macOS - What the approver needs differs per operation. Split the tool
- If you are going to refuse, refuse before the click
- Never truncate what is being approved. Only what is displayed
- "Still clickable" is testable as size does not depend on content
The app this came out of is Wisp — a
desktop agent with a 3D body that runs commands and writes files, and shows you
exactly what it is about to do first.
Top comments (0)