Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ These bindings are active when focus is in the chat input field.
| `Ctrl+G` | goToLine | Jump to a specific line |
| `Ctrl+.` | jumpToNextError | Jump to next error / issue |
| `Ctrl+Shift+.` | jumpToPreviousError | Jump to previous error / issue |
| `Alt+I` | toggleInspector | Next renderer only: open or close the inspector |
| `Alt+L` | jumpToLive | Next renderer only: return a scrolled transcript to live output |

> `Ctrl+A` previously opened the model picker; it now moves the cursor to the line start (matching Emacs/readline). The model picker is now `Ctrl+Shift+A`. Old `keybindings.json` files are auto-migrated.

Expand All @@ -92,6 +94,21 @@ These bindings are active when Coven Code is displaying a yes/no confirmation pr

---

### Inspector Context (Next renderer)

Active while the Next renderer's inspector is open (`COVEN_CODE_UI=next`). It
is layered over the chat context: keys it does not bind keep their chat
meaning, so the draft stays editable and `Enter` still submits. Classic leaves
the `Alt+I` and `Alt+L` chords unbound.

| Key | Action | Description |
|-----|--------|-------------|
| `Escape` / `Alt+I` | closeInspector | Close the inspector and return to the transcript |
| `Tab` | nextInspectorPage | Next page (Activity, Session, Views) |
| `Shift+Tab` | previousInspectorPage | Previous page |
| `Page Up` | scrollInspectorUp | Scroll the inspector up |
| `Page Down` | scrollInspectorDown | Scroll the inspector down |

## Keybinding Contexts

Coven Code uses a context system so that the same key can have different effects depending on where focus is. A binding in a more specific context takes precedence over a binding in a broader context.
Expand All @@ -101,6 +118,7 @@ Coven Code uses a context system so that the same key can have different effects
| `global` | Always active regardless of focus |
| `chat` | Active when the chat input field has focus |
| `confirmation` | Active when a permission confirmation dialog is open |
| `inspector` | Active while the Next inspector is open; layered over `chat` |
| `modelPicker` | Active inside the model selection overlay |
| `commandPalette` | Active inside the slash command palette overlay |
| `search` | Active while the inline search bar is open |
Expand Down Expand Up @@ -303,6 +321,8 @@ Pressing `Escape` while the model is streaming a response interrupts the stream.

This is equivalent to pressing `Ctrl+C` during streaming, except that `Ctrl+C` also signals any tool calls in progress to abort (via `AbortController`), while `Escape` only stops the stream and allows running tools to finish.

In the Next renderer (`COVEN_CODE_UI=next`), `Escape` cancels the whole turn the same way `Ctrl+C` does, and the status shows `cancelling` until the runtime has actually stopped it. When the Next inspector is open, the first `Escape` closes the inspector instead of cancelling.

---

### @file Injection with Typeahead
Expand Down
158 changes: 158 additions & 0 deletions scripts/tui-tests/cases/08_next_shell.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# Next shell journey (#410): COVEN_CODE_UI=next from a fresh, unconfigured
# home through draft -> inspector -> paste -> submit -> stream -> scroll ->
# resize -> jump to live -> cancel -> quit, then terminal restoration.
#
# The turn runs through the engine's keyless Claude transport against a fake
# `claude` that answers once and then keeps the turn running, so streaming,
# reading position and cancellation are exercised with no network or model.
# The terminal is resized between steps (120x40, 80x24, 40x12, back to
# 120x40). This proves the shell journey only; it is not evidence for tool,
# approval, task, research or memory behavior.

register_case tc_next_shell

_next_shell_fake_claude() {
cat >"$1" <<'FAKE'
#!/bin/sh
# Fake `claude`: one 60-line answer, then a turn that does not finish.
cat >/dev/null &
printf '%s\n' '{"type":"system","subtype":"init","session_id":"fake","model":"fake-model"}'
i=1; text=""
while [ "$i" -le 60 ]; do text="${text}fake line $i\\n"; i=$((i + 1)); done
printf '%s\n' "{\"type\":\"assistant\",\"message\":{\"model\":\"fake-model\",\"content\":[{\"type\":\"text\",\"text\":\"${text}\"}]}}"
sleep 6
touch "$FAKE_CLAUDE_DONE"
printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":3,"output_tokens":1}}'
FAKE
chmod +x "$1"
}

tc_next_shell() {
describe "Next shell journey (COVEN_CODE_UI=next)"
if ! have_tmux; then _skip "tmux not installed"; return 0; fi

local scratch; scratch="$(mktemp -d)"
mkdir -p "$scratch/home/.coven-code" "$scratch/bin" "$scratch/acme-next"
printf '%s\n' '{"hasCompletedOnboarding": true}' >"$scratch/home/.coven-code/settings.json"
_next_shell_fake_claude "$scratch/bin/claude"

local saved_prefix="$TUI_LAUNCH_PREFIX" saved_boot="$TUI_BOOT_STRING"
# Scrub anything that could reach a real provider or engine home.
TUI_LAUNCH_PREFIX="cd $(printf '%q' "$scratch/acme-next") && env"
local var
for var in ANTHROPIC_API_KEY ANTHROPIC_BASE_URL OPENAI_API_KEY COVEN_CODE_PROVIDER \
COVEN_CODE_API_BASE COVEN_CODE_HOME COVEN_CODE_TEST_HOME COVEN_PARENT COVEN_DAEMON_SOCKET; do
TUI_LAUNCH_PREFIX+=" -u $var"
done
TUI_LAUNCH_PREFIX+=" $(printf '%q ' \
"HOME=$scratch/home" "COVEN_HOME=$scratch/home/.coven" COVEN_CODE_UI=next \
"COVEN_CODE_CLAUDE_BIN=$scratch/bin/claude" "FAKE_CLAUDE_DONE=$scratch/done")"
TUI_BOOT_STRING="preview"

if tui_start; then
local s
s="$(tui_capture)"
assert_contains "$s" "acme-next" "workspace line names the project"
assert_contains "$s" "ready" "fresh home opens idle with no provider action"
assert_absent "$s" "Coven v" "Next draws its own shell, not Classic's welcome"

# ---- draft and the one inspector ------------------------------------
_tmux resize-window -t "$TUI_SESSION" -x 80 -y 24
tui_type "draft one"
if wait_for "draft not sent" 5; then _pass "a typed draft is visibly unsent"
else _fail "a typed draft is visibly unsent" "$(tui_capture)"; fi
tui_keys M-i
if wait_for "Activity" 5; then _pass "Alt+I opens the inspector"
else _fail "Alt+I opens the inspector" "$(tui_capture)"; fi
tui_keys Tab
if wait_for "Credentials" 5; then _pass "Tab moves to the Session page"
else _fail "Tab moves to the Session page" "$(tui_capture)"; fi
tui_keys Escape
if wait_absent "Credentials" 5; then _pass "Escape closes the inspector"
else _fail "Escape closes the inspector" "$(tui_capture)"; fi
assert_contains "$(tui_capture)" "draft one" "the draft survives the inspector"

# ---- pasted newline and slash text never submit ----------------------
tui_keys C-u
tui_paste "pasted one"$'\r'"/clear"
if wait_for "/clear" 5; then
tui_settle
s="$(tui_capture)"
assert_contains "$s" "draft not sent" "pasted newline and /clear stay in the draft"
assert_absent "$s" "you" "nothing was submitted to the transcript"
else
_fail "pasted text lands in the composer" "$(tui_capture)"
fi
tui_keys C-u

# ---- submit, stream, scroll away, resize, back to live ---------------
_tmux resize-window -t "$TUI_SESSION" -x 120 -y 40
tui_type "stream please"; tui_keys Enter
if wait_for "fake line 60" 15; then _pass "the response streams into the transcript"
else _fail "the response streams into the transcript" "$(tui_capture)"; fi
tui_keys PageUp
if wait_for "scrolled up" 5; then _pass "scrolling away shows the way back to live"
else _fail "scrolling away shows the way back to live" "$(tui_capture)"; fi
local size
for size in "80 24" "40 12"; do
_tmux resize-window -t "$TUI_SESSION" -x "${size% *}" -y "${size#* }"
tui_settle
s="$(tui_capture)"
assert_contains "$s" "scrolled up" "the scrolled view survives a resize to ${size/ /x}"
assert_contains "$s" "fake line" "transcript text is still shown at ${size/ /x}"
assert_contains "$s" "working" "the turn is still running at ${size/ /x}"
done
_tmux resize-window -t "$TUI_SESSION" -x 120 -y 40
tui_keys M-l
if wait_absent "scrolled up" 5; then _pass "Alt+L jumps back to live output"
else _fail "Alt+L jumps back to live output" "$(tui_capture)"; fi

# ---- cancel: the runtime stops the turn ------------------------------
tui_keys Escape
if wait_for "ready" 10; then _pass "Escape cancels the running turn"
else _fail "Escape cancels the running turn" "$(tui_capture)"; fi
local waited=0
while pgrep -f "$scratch/bin/claude" >/dev/null 2>&1 && [ "$waited" -lt 10 ]; do
sleep 0.5; waited=$((waited + 1))
done
if pgrep -f "$scratch/bin/claude" >/dev/null 2>&1; then
_fail "the cancelled turn's claude process exits"
else
_pass "the cancelled turn's claude process exits"
fi
# The fake finishes 6s after answering; give it the time it would need.
sleep 7
if [ -e "$scratch/done" ]; then _fail "the cancelled turn did not run to completion"
else _pass "the cancelled turn did not run to completion"; fi
assert_contains "$(tui_capture)" "Cancelled." "the settled outcome is shown"

# ---- quit and terminal restoration -----------------------------------
tui_keys C-c; sleep 0.3; tui_keys C-c
# Type the check only once the shell owns the pane again; keys sent
# while the app is still exiting are read by it and lost.
local waited=0
while [ "$waited" -lt 50 ] && \
[ "$(_tmux display-message -p -t "$TUI_SESSION" '#{pane_current_command}')" = "coven-code" ]; do
sleep 0.1; waited=$((waited + 1))
done
tui_type 'stty -a | grep -q -- "-icanon" && echo "RAW_$((40 + 1))" || echo "COOKED_$((40 + 2))"'
tui_keys Enter
if wait_for "COOKED_42" 10; then _pass "quitting restores cooked terminal mode"
else _fail "quitting restores cooked terminal mode" "$(tui_capture)"; fi
assert_eq "$(_tmux display-message -p -t "$TUI_SESSION" '#{alternate_on}')" "0" \
"quitting leaves the alternate screen"
assert_eq "$(_tmux display-message -p -t "$TUI_SESSION" '#{cursor_flag}')" "1" \
"quitting restores the cursor"
assert_eq "$(_tmux display-message -p -t "$TUI_SESSION" '#{mouse_any_flag}')" "0" \
"quitting turns mouse reporting off"
fi
tui_stop

TUI_LAUNCH_PREFIX="$saved_prefix"
TUI_BOOT_STRING="$saved_boot"
pkill -f "$scratch/bin/claude" 2>/dev/null || true
rm -rf "$scratch"
}
3 changes: 2 additions & 1 deletion scripts/tui-tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ set -uo pipefail
: "${TUI_SETTLE:=0.6}" # seconds to let a keypress redraw
: "${TUI_LOG_DIR:=}" # if set, failing-case pane captures are written here
: "${REQUIRE_TMUX:=0}" # if 1, a missing tmux is a hard error (CI), not a skip
: "${TUI_LAUNCH_PREFIX:=}" # shell text typed before the binary (cd, env); cases set and restore it

# --- counters (persist across sourced case files) -----------------------
TESTS_RUN=0
Expand Down Expand Up @@ -238,7 +239,7 @@ tui_start() {

local args=""
if [ "$#" -gt 0 ]; then printf -v args ' %q' "$@"; fi
tui_type "$COVEN_BIN$args"; tui_keys Enter
tui_type "$TUI_LAUNCH_PREFIX$COVEN_BIN$args"; tui_keys Enter

if wait_for "$TUI_BOOT_STRING" "$TUI_WAIT_TIMEOUT"; then
return 0
Expand Down
21 changes: 21 additions & 0 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,9 @@ fn abandon_interactive_turn(
if let Some(cancel) = cancel {
cancel.cancel();
}
// This is the cancellation a key may have requested; it now waits on
// the worker, which the loop settles once the turn has stopped.
app.take_cancellation_request();
}

#[tokio::main]
Expand Down Expand Up @@ -2735,6 +2738,7 @@ async fn run_interactive(
app.bell_on_complete = settings.bell_on_complete;
let user_keybindings =
claurst_core::keybindings::UserKeybindings::load(&Settings::config_dir());
app.renderer = renderer;
app.next_presentation =
claurst_tui::next::NextPresentation::from_settings(&settings, &user_keybindings, |name| {
std::env::var(name).ok()
Expand Down Expand Up @@ -3069,6 +3073,7 @@ async fn run_interactive(
claurst_tui::application::UiRenderer::Classic => render_app(f, &app),
claurst_tui::application::UiRenderer::Next => claurst_tui::next::render_next(f, &app),
})?;
app.settle_scroll_anchor();

// Poll for crossterm events (keyboard/mouse) with short timeout
// unless an auto-submit (queued message) is pending — in which case
Expand Down Expand Up @@ -4505,6 +4510,11 @@ async fn run_interactive(
}
Ok(TuiBridgeEvent::Cancelled) => {
abandon_interactive_turn(&mut goal_turn, &mut app, &cancel);
if current_query.is_some()
&& app.renderer == claurst_tui::application::UiRenderer::Next
{
app.note_cancellation_sent();
}
app.is_streaming = false;
app.status_message = Some("Cancelled by remote control.".to_string());
}
Expand Down Expand Up @@ -5003,6 +5013,12 @@ async fn run_interactive(
}
}

// Escape and the interrupt key record a cancel request in the App;
// perform it through the turn's token so the runtime actually stops.
if app.take_cancellation_request() && current_query.is_some() {
abandon_interactive_turn(&mut goal_turn, &mut app, &cancel);
}

// Check if query task is done; sync messages from the task
let task_finished = current_query
.as_ref()
Expand Down Expand Up @@ -5225,6 +5241,11 @@ async fn run_interactive(
}
}

// A cancel is confirmed only once no worker is left running.
if current_query.is_none() {
app.settle_cancellation();
}

if !app.is_streaming && current_query.is_none() {
if let Some(server_name) = app.take_pending_mcp_panel_auth() {
let server_config = cmd_ctx
Expand Down
Loading
Loading