Summary
/hooks edit (also /hooks add, /hooks new) launches $VISUAL/$EDITOR on
.codewhale/hooks.toml without pausing the TUI terminal-input thread. The TUI keeps
reading stdin while the editor owns the terminal, so the user's keystrokes are split
between the two readers. The result is that the editor cannot be quit interactively
(Esc, :q!, Enter never arrive intact), the TUI simultaneously swallows the same
keystrokes into the composer, and the session looks completely frozen.
Every other place that hands the terminal to a child does perform the handshake
(Ctrl+Shift+O composer editor, interactive shell commands). This one path was missed.
Environment
- codewhale
0.9.13 (npm wrapper 0.9.13 + native binary built from the codewhale-cli
0.9.13 crate; codewhale --version → codewhale 0.9.13)
- Linux, xfce4-terminal, PTY
271x49, TERM=xterm-256color
$VISUAL and $EDITOR both unset → the documented vi fallback is used
- Editor observed:
vi (vim), which creates .hooks.toml.swp in .codewhale/
Steps to reproduce
- Start the TUI in a trusted workspace.
- Type
/hooks edit and press Enter.
vi opens on <workspace>/.codewhale/hooks.toml (template is seeded on first use).
- Press
Esc, then :q!, then Enter.
Expected behavior
vi exits, control returns to the TUI, the Hooks screen reports "Hooks unchanged." — the
normal external-editor round trip.
Actual behavior
- The editor does not exit. Nothing the user types is enough to leave it.
- The keystrokes are partially delivered: in a controlled run,
: and ! landed in
vi's command line, while Esc and Enter were consumed by the TUI. The editor ends up
in an unusable half-state (command line open, no way to confirm or cancel).
- The TUI consumes the rest, so fragments of the user's editing are submitted as composer
input. In the reported session, ~/.deepseek/composer_history.txt contained the entries
q, !!, q sitting between two genuine prompts — consistent with the pieces of a
:q! quit attempt being captured by the TUI (including its Enter) instead of reaching
the editor.
- The session appears hung. The only reliable recovery is to kill the editor (or the
session) from a different terminal — which the user cannot do if the TUI is their only
terminal.
Root cause
The TUI reads stdin on a dedicated pump thread, TerminalInputPump:
codewhale-tui/src/tui/ui/terminal_input.rs:77 — spawn_parts() spawns the
codewhale-terminal-input thread.
codewhale-tui/src/tui/ui/terminal_input.rs:90-98 — the loop only stops reading when
thread_paused is set; otherwise it runs event::poll(...) and event::read()
continuously, consuming keystrokes that a foreground child should own.
codewhale-tui/src/tui/ui/terminal_input.rs:199 — pause_for_child_terminal() is the
only mechanism that makes the pump yield the terminal (it waits for paused_ack, with a
500 ms timeout).
with_suspended_tui() (codewhale-tui/src/tui/external_editor.rs:200) handles only
crossterm state — disable_raw_mode() at :222, leave_alt_screen() at :224, and the
mirror calls at :231/:233. It has no access to the pump and therefore never pauses it.
Suspending raw mode alone is not sufficient: the pump thread keeps calling event::read()
on the same tty regardless of the terminal's mode.
The two external-editor call sites differ:
Correct — composer editor (Ctrl+Shift+O), codewhale-tui/src/tui/ui/event_loop.rs:
// :6505
let editor_result = terminal_input.pause_for_child_terminal().and_then(|()| {
let result = prepare_terminal_input_handoff(&terminal_input, &mut pending_terminal_events)
.and_then(|ready| {
if ready {
crate::tui::external_editor::spawn_editor_for_input(/* :6512 */ )
} else { /* refuse handoff on pending Esc / Ctrl+C */ }
});
terminal_input.resume_after_child_terminal(); // :6526
result
});
This does all three things: pause the pump, drain buffered input so it cannot leak into the
child (prepare_terminal_input_handoff, codewhale-tui/src/tui/ui/terminal.rs:44), and
refuse the handoff if a cancellation key is pending. The same pattern is used for
interactive shells (event_loop.rs:3191).
Broken — /hooks edit, codewhale-tui/src/tui/ui/apply.rs:
// :2075
AppAction::EditProjectHooks => {
edit_project_hooks_from_tui(terminal, app, config); // no terminal_input available
}
// :2550
fn edit_project_hooks_from_tui(terminal: &mut AppTerminal, app: &mut App, config: &Config) {
// ...
// :2565
let outcome = crate::tui::external_editor::spawn_editor_for_path(
terminal,
app.use_alt_screen(),
app.use_mouse_capture,
app.use_bracketed_paste,
&path,
);
}
Neither the action handler nor edit_project_hooks_from_tui has a &TerminalInputPump.
The signature makes the handshake structurally impossible at this layer, so it can never be
added by accident — it was simply never wired up.
Note the same asymmetry exists between spawn_editor_for_input (used by the correct path)
and spawn_editor_for_path (used by the broken one): both call with_suspended_tui, so
both look correct, but the pause lives in the caller and only one caller has it.
Controlled reproduction
Run on the reporter's machine against 0.9.13, in an isolated workspace so no real project
files were touched. Full script: codewhale-issue-experiment.py (attached alongside this
report). It allocates its own PTY, runs a throwaway codewhale, drives the UI, and checks
ps for the spawned vi.
Observed output (abridged):
>>> workspace-trust prompt detected; pressing 1 (trust this workspace)
>>> typing: /hooks edit (char by char)
[check 1] vi processes: ['3984267 3981902 Sl+ pts/34 vi /tmp/.../work/.codewhale/hooks.toml']
>>> injecting exit keys: ESC : q ! ENTER (spread out)
[check 2] vi processes after exit keys: ['3984267 3981902 Sl+ pts/34 vi /tmp/.../work/.codewhale/hooks.toml']
RESULT: vi STILL RUNNING -> keystrokes swallowed (reproduced)
The final screen dump of the PTY shows vi's status line ending in ::! — the : and !
of the exit sequence reached the editor, but Esc and Enter did not. That split is the
signature of two readers competing for one tty, and it is reproducible on demand.
Impact
/hooks edit is unusable with any terminal editor (vi, vim, nano, emacs -nw),
which is exactly the fallback the resolver picks when $VISUAL/$EDITOR are unset
(external_editor.rs:40-50).
- The failure mode is maximally confusing: the editor partially responds, so it does not
look like a missing handoff — it looks like a hung agent.
- The user's typed characters are captured as composer input, so failed quit attempts are
submitted as messages to the model.
- Recovery requires a second terminal. On a single-terminal setup the whole session is
lost.
Related path with the same defect
codewhale-tui/src/tui/history.rs:2937 (try_open_file_at_line, invoked on mouse click of a
path:line in tool output via codewhale-tui/src/tui/mouse_ui.rs:1680) spawns $EDITOR
with Command::new(&editor).spawn() at :2963 and never suspends the TUI at all — not even
raw mode or the alt screen. Its fallback is also vim. Terminal editors launched from that
path compete for the same tty while the TUI keeps rendering.
Companion defect: the same missing invariant, in the other direction
There is a mirror image of this bug, reported separately in
codewhale-issue-sigttin-suspend-poisons-terminal.md: the TUI's own process group can end
up in the background, at which point its next read of the tty raises SIGTTIN and the kernel
stops the whole group mid-turn. Because the TUI installs no SIGTSTP/SIGTTIN/SIGCONT
handler, the very modes this file's code path also enables — raw mode, mouse capture,
bracketed paste, the alternate screen — are never torn down, and the terminal goes on to
"poison the shell with raw escape reports"
(codewhale-tui/src/tui/ui/terminal.rs:119-120).
The two defects are opposite failures of the same thing. Here the TUI keeps competing for a
tty that a child owns; there the TUI loses its claim on the tty entirely and is stopped by
the kernel. In both cases terminal ownership is implemented per call-site rather than as
one enforced invariant — every "hand the terminal to a child" and every "take it back"
decision is made by whichever call site remembers to make it.
Test gap
There are unit tests for the handshake primitives themselves —
codewhale-tui/src/tui/ui/tests.rs:25635
(terminal_input_child_pause_drains_codewhale_events_before_editor_handoff) and :25686
(terminal_input_handoff_preserves_pending_cancellation_keys) — but nothing asserts that
each editor entry point uses them. spawn_editor_for_path and
edit_project_hooks_from_tui have no test coverage at all, which is why the missing pairing
went unnoticed.
Suggested fix
Items 1–4 each close this bug; item 5 is what keeps it closed.
- Thread
&TerminalInputPump down to edit_project_hooks_from_tui and wrap the
spawn_editor_for_path call in the same pause → prepare_terminal_input_handoff → resume sequence used at event_loop.rs:6505. Extract that sequence into one helper so
both call sites share it.
- Move the pause into
with_suspended_tui by giving it access to the pump, so every
caller is correct by construction and the "caller must remember to pause" invariant
disappears.
- Add a regression test that enumerates external-editor entry points and asserts each one
pauses the pump before spawning — or a PTY-level integration test in the style of the
reproduction script above.
- Whatever the fix, consider documenting that
$EDITOR must be a GUI editor or one that
returns immediately (code --wait, subl -w), since that is currently the only safe
configuration for the terminal-editor paths.
- Reduce all of the above to one invariant instead of more call-site fixes: a single
"terminal ownership" abstraction that owns raw mode, the mouse/paste/alt-screen modes and
the input pump together, and that every path must pass through — handing the terminal to a
child, stopping for a job-control signal, resuming, or exiting. Items 1–2 fix this entry
point; only this generalises to the companion defect above, and to the next call site
nobody has written yet.
Reporter notes
- The
.hooks.toml.swp swap file is left behind when the editor is killed, and the
template file itself is unchanged; no data was lost in the reported incident.
- Killing the editor from another terminal (
kill -TERM <vi pid>) does restore the TUI, so
the TUI side is not itself deadlocked — it is blocked in wait() on the child while its
input pump keeps stealing keystrokes.
Summary
/hooks edit(also/hooks add,/hooks new) launches$VISUAL/$EDITORon.codewhale/hooks.tomlwithout pausing the TUI terminal-input thread. The TUI keepsreading stdin while the editor owns the terminal, so the user's keystrokes are split
between the two readers. The result is that the editor cannot be quit interactively
(
Esc,:q!,Enternever arrive intact), the TUI simultaneously swallows the samekeystrokes into the composer, and the session looks completely frozen.
Every other place that hands the terminal to a child does perform the handshake
(
Ctrl+Shift+Ocomposer editor, interactive shell commands). This one path was missed.Environment
0.9.13(npm wrapper0.9.13+ native binary built from thecodewhale-cli0.9.13crate;codewhale --version→codewhale 0.9.13)271x49,TERM=xterm-256color$VISUALand$EDITORboth unset → the documentedvifallback is usedvi(vim), which creates.hooks.toml.swpin.codewhale/Steps to reproduce
/hooks editand pressEnter.viopens on<workspace>/.codewhale/hooks.toml(template is seeded on first use).Esc, then:q!, thenEnter.Expected behavior
viexits, control returns to the TUI, the Hooks screen reports "Hooks unchanged." — thenormal external-editor round trip.
Actual behavior
:and!landed invi's command line, while
EscandEnterwere consumed by the TUI. The editor ends upin an unusable half-state (command line open, no way to confirm or cancel).
input. In the reported session,
~/.deepseek/composer_history.txtcontained the entriesq,!!,qsitting between two genuine prompts — consistent with the pieces of a:q!quit attempt being captured by the TUI (including itsEnter) instead of reachingthe editor.
session) from a different terminal — which the user cannot do if the TUI is their only
terminal.
Root cause
The TUI reads stdin on a dedicated pump thread,
TerminalInputPump:codewhale-tui/src/tui/ui/terminal_input.rs:77—spawn_parts()spawns thecodewhale-terminal-inputthread.codewhale-tui/src/tui/ui/terminal_input.rs:90-98— the loop only stops reading whenthread_pausedis set; otherwise it runsevent::poll(...)andevent::read()continuously, consuming keystrokes that a foreground child should own.
codewhale-tui/src/tui/ui/terminal_input.rs:199—pause_for_child_terminal()is theonly mechanism that makes the pump yield the terminal (it waits for
paused_ack, with a500 ms timeout).
with_suspended_tui()(codewhale-tui/src/tui/external_editor.rs:200) handles onlycrossterm state —
disable_raw_mode()at:222,leave_alt_screen()at:224, and themirror calls at
:231/:233. It has no access to the pump and therefore never pauses it.Suspending raw mode alone is not sufficient: the pump thread keeps calling
event::read()on the same tty regardless of the terminal's mode.
The two external-editor call sites differ:
Correct — composer editor (
Ctrl+Shift+O),codewhale-tui/src/tui/ui/event_loop.rs:This does all three things: pause the pump, drain buffered input so it cannot leak into the
child (
prepare_terminal_input_handoff,codewhale-tui/src/tui/ui/terminal.rs:44), andrefuse the handoff if a cancellation key is pending. The same pattern is used for
interactive shells (
event_loop.rs:3191).Broken —
/hooks edit,codewhale-tui/src/tui/ui/apply.rs:Neither the action handler nor
edit_project_hooks_from_tuihas a&TerminalInputPump.The signature makes the handshake structurally impossible at this layer, so it can never be
added by accident — it was simply never wired up.
Note the same asymmetry exists between
spawn_editor_for_input(used by the correct path)and
spawn_editor_for_path(used by the broken one): both callwith_suspended_tui, soboth look correct, but the pause lives in the caller and only one caller has it.
Controlled reproduction
Run on the reporter's machine against
0.9.13, in an isolated workspace so no real projectfiles were touched. Full script:
codewhale-issue-experiment.py(attached alongside thisreport). It allocates its own PTY, runs a throwaway
codewhale, drives the UI, and checkspsfor the spawnedvi.Observed output (abridged):
The final screen dump of the PTY shows vi's status line ending in
::!— the:and!of the exit sequence reached the editor, but
EscandEnterdid not. That split is thesignature of two readers competing for one tty, and it is reproducible on demand.
Impact
/hooks editis unusable with any terminal editor (vi,vim,nano,emacs -nw),which is exactly the fallback the resolver picks when
$VISUAL/$EDITORare unset(
external_editor.rs:40-50).look like a missing handoff — it looks like a hung agent.
submitted as messages to the model.
lost.
Related path with the same defect
codewhale-tui/src/tui/history.rs:2937(try_open_file_at_line, invoked on mouse click of apath:linein tool output viacodewhale-tui/src/tui/mouse_ui.rs:1680) spawns$EDITORwith
Command::new(&editor).spawn()at:2963and never suspends the TUI at all — not evenraw mode or the alt screen. Its fallback is also
vim. Terminal editors launched from thatpath compete for the same tty while the TUI keeps rendering.
Companion defect: the same missing invariant, in the other direction
There is a mirror image of this bug, reported separately in
codewhale-issue-sigttin-suspend-poisons-terminal.md: the TUI's own process group can endup in the background, at which point its next read of the tty raises
SIGTTINand the kernelstops the whole group mid-turn. Because the TUI installs no
SIGTSTP/SIGTTIN/SIGCONThandler, the very modes this file's code path also enables — raw mode, mouse capture,
bracketed paste, the alternate screen — are never torn down, and the terminal goes on to
"poison the shell with raw escape reports"
(
codewhale-tui/src/tui/ui/terminal.rs:119-120).The two defects are opposite failures of the same thing. Here the TUI keeps competing for a
tty that a child owns; there the TUI loses its claim on the tty entirely and is stopped by
the kernel. In both cases terminal ownership is implemented per call-site rather than as
one enforced invariant — every "hand the terminal to a child" and every "take it back"
decision is made by whichever call site remembers to make it.
Test gap
There are unit tests for the handshake primitives themselves —
codewhale-tui/src/tui/ui/tests.rs:25635(
terminal_input_child_pause_drains_codewhale_events_before_editor_handoff) and:25686(
terminal_input_handoff_preserves_pending_cancellation_keys) — but nothing asserts thateach editor entry point uses them.
spawn_editor_for_pathandedit_project_hooks_from_tuihave no test coverage at all, which is why the missing pairingwent unnoticed.
Suggested fix
Items 1–4 each close this bug; item 5 is what keeps it closed.
&TerminalInputPumpdown toedit_project_hooks_from_tuiand wrap thespawn_editor_for_pathcall in the samepause → prepare_terminal_input_handoff → resumesequence used atevent_loop.rs:6505. Extract that sequence into one helper soboth call sites share it.
with_suspended_tuiby giving it access to the pump, so everycaller is correct by construction and the "caller must remember to pause" invariant
disappears.
pauses the pump before spawning — or a PTY-level integration test in the style of the
reproduction script above.
$EDITORmust be a GUI editor or one thatreturns immediately (
code --wait,subl -w), since that is currently the only safeconfiguration for the terminal-editor paths.
"terminal ownership" abstraction that owns raw mode, the mouse/paste/alt-screen modes and
the input pump together, and that every path must pass through — handing the terminal to a
child, stopping for a job-control signal, resuming, or exiting. Items 1–2 fix this entry
point; only this generalises to the companion defect above, and to the next call site
nobody has written yet.
Reporter notes
.hooks.toml.swpswap file is left behind when the editor is killed, and thetemplate file itself is unchanged; no data was lost in the reported incident.
kill -TERM <vi pid>) does restore the TUI, sothe TUI side is not itself deadlocked — it is blocked in
wait()on the child while itsinput pump keeps stealing keystrokes.