← Wisp

My approval dialog grew until the buttons went off screen

Showing the user everything the agent is about to do, and letting them actually choose, are two different properties. I only had the first one, and it took a demo recording to find out.

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:

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 writtendialog
4,558 bytes of HTML16 lines
34,889 bytes17 lines

Three more decisions came with it:

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


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.