Centered overlays that take over the viewport until dismissed. Each modal
declares a recess policy — Modal::recess, the single source of truth that
both the footer-collapse flag and the per-frame paint consult — describing how
the surface beneath it recedes. A terminal cannot alpha-blend, so recess is
expressed in one of three ways:
- Dim (most centered modals): the footer keeps its height and the whole
live surface — transcript, activity bar, input box, hint line — is darkened
in place so it stays visible for context while the centered panel reads as
the focal layer. The brightness is the
modal_dim_factortheme field. - Takeover (the sessions picker only): the footer collapses to zero height and the surface is fully occluded — a clean slate for a context switch.
- None (question modal; the permission sheet is inline): floats on the fully-live surface with no dimming.
Every centered modal uses the same low-level primitives and, where possible,
the shared modal component in
crates/neenee-cli/src/tui/components/modal.rs:
recess_backdrop(frame, modal.recess(), theme)is called once per frame by the event loop after the transcript and chrome are drawn and before the centered panel. For a Dim modal it scales every cell's color bytheme.modal_dim_factor()(background stays visible); for Takeover it clears + fills withtheme.backdrop()(full occlusion); for None it is a no-op.centered_rect(px_w, px_h, viewport)carves the modal rectangle out of the viewport (the frame minus the global 1-row top/bottom margin). The surrounding gutters are kept asapp_bg.modal_frame(area, theme.panel(), header, footer)produces a borderless solid-bg panel with 2-col horizontal and 1-row vertical inner padding, vertically split intoheader(Length 1) → gap(Length 1) → body(Min 0) → gap(Length 1) → footer(Length 1). Header/footer/gap rows are omitted when not requested.draw_modal_page(ModalPage { ... })composes geometry, frame chrome, header,ScrollBody, and modal footer hints for simple centered modals.draw_selectable_list_page(SelectableListPage { ... })adds selected-row follow scrolling and item/empty footer selection for list modals.
┌──── centered_rect(px_w, px_h) ────┐
app_bg gutter│ ░░ modal padding (top, full row) │app_bg gutter
│ Header · brand+muted │
│ │
│ Body (scrollable, follow=sel.) │
│ │
│ Footer · muted │
app_bg gutter│ ░░ modal padding (bot, full row) │app_bg gutter
└───────────────────────────────────┘
The two toasts are non-modal and use ToastBubble from
components/toast.rs.
| Modal | Trigger | centered_rect |
Source |
|---|---|---|---|
| Models | Ctrl+M / /models |
72 × 60 | draw_models_modal |
| Connections | /connections |
72 × 60 | draw_connections_modal |
| Model editor | Models or Connections modal e |
60 × 36 | draw_model_editor |
| Sessions | /sessions |
80 × 64 | draw_sessions_modal |
| Tools | /tools |
64 × content | draw_tools_modal |
| History search | Ctrl+R |
70 × 72 | draw_history_modal |
| Question | ask_user tool |
78 × 70 | draw_question_modal |
| Permission sheet | Automatic | (inline, not centered) | draw_permission_sheet |
| Help | Ctrl+H / ? / F1 / /help |
58 × 70 | draw_help_modal |
| Activity | Click activity bar | 72 × 70 | draw_activity_modal |
| Toasts | Transient | top-right, 3 rows | draw_armed_toast, draw_copy_toast |
EscorCtrl+Ccloses most modals.- Permission sheet:
Escrejects;Ctrl+Ccloses and rejects. - Model editor:
Ctrl+Crestores the stashed composer input and exits the configuration flow.
Click-outside-to-dismiss. Read-only / info modals — Help, Tool-step
detail, Tools, Sessions, Permissions, Activity, History, and the two pickers
(Models, Connections) — close when the user clicks outside their panel,
mirroring Esc. Entry modals that hold precious in-progress input (Model
editor) and the decision modals (Question, Permission sheet) stay open so an
accidental click never discards an API key or a pending decision. The single
source of truth is Modal::dismissable_by_outside_click().
Flat (provider, model) picker — the daily-driver switch surface. Every model served by every configured connection appears as its own row, ranked provider-favorite first, then per-model last-used, then name. Borrows the composer input as a fuzzy filter over the model label (a query that matches only the provider name keeps its rows, unhighlighted).
╭───────────────────────────────────────────────╮
│ Models ❯ opus │ ← header (real caret here)
│ │
│ ● Claude Opus 4.8 · anthropic ◆ think on │ ← selected → brand bg
│ ● gpt-4o · openai │
│ ● gemini-3-pro · google │
│ … │
│ │
│ type to filter · ↑↓ navigate · enter activate │
│ e settings · esc │
╰───────────────────────────────────────────────╯
| Key | Effect |
|---|---|
| printable | Append to the filter (composer is the input source) |
↑ / ↓ |
Move selection |
Enter |
Activate the highlighted (provider, model) row |
e |
Open the per-model settings editor (effort / thinking) |
d |
Remove the highlighted model from a custom provider |
Esc |
Close |
Ctrl+M opens this modal only on terminals that support the Kitty enhanced
keyboard protocol. In a raw terminal Ctrl+M is byte-identical to Enter,
so on unsupported terminals the key falls through to Enter and /models
is the reliable trigger.
Provider-instance management surface. Rows are the configured provider
instances, ranked last-used → name; each row shows the instance name and its
provider type (· OpenAI). This surface only manages instances — it has
no activate concept, so switching the active provider is done from the Models
picker. When no instance exists, an empty-state hint prompts the user to press
a.
| Key | Effect |
|---|---|
| printable | Append to the filter (composer is the input source) |
↑ / ↓ |
Move selection |
/ |
Enter the search sub-layer (Esc clears it) |
a |
Add a connection — open the provider-template chooser |
e |
Edit — API key for built-ins, full meta editor for custom providers |
D |
Delete a custom provider (confirm overlay) |
Esc |
Close |
Unified API-key + model-id editor (ADR-0002 phase 4). Two fields with Tab
cycling focus; the composer input is the value of the focused field, the
unfocused one is held in a buffer.
╭───────────────────────────────────╮
│ Edit · openai │
│ │
│ API key •••••••••••••••• │ ← muted (unfocused, masked)
│ Model id gpt-4o │ ← bold brand label (focused, caret)
│ │
│ tab switch field · enter save · esc cancel │
╰───────────────────────────────────╯
The API key is masked as • per character whenever it is not focused.
| Key | Effect |
|---|---|
| printable | Append to the focused field |
Tab |
Cycle focus between API key and Model id |
Enter |
Save the focused field and switch to the other |
Esc / Ctrl+C |
Cancel and restore the stashed composer input |
Sessions picker. Each row shows an overview plus created/active relative
times; Enter resumes the selected session.
╭──────────────────────────────────────────────────────────╮
│ Sessions │
│ │
│ ● fix login redirect bug created 2h · active 3m │ ← active + selected
│ refactor database layer created 1d · active 5h │
│ write API docs created 3d · active 2d │
│ │
│ ↑↓ navigate · Enter open · d delete · Esc close │
╰──────────────────────────────────────────────────────────╯
The ● badge marks the currently active session. Overview text is
truncated with … when it would collide with the meta column.
Interactive tool manager, opened by /tools (or t/Enter from the Session
dashboard's TOOLS line). A centered, scrollable list of every tool available
to the live session — builtins and mcp:<server> tools — each with
its source, a short description, and an [on]/[off] badge. Space toggles a
tool; the harness applies it and replies with a fresh snapshot that re-renders
the list. Data comes from the session-context snapshot.
╭────────────────────────────────────────────────────────╮
│ Tools │
│ │
│ ● bash builtin run a shell command[on]│ ← selected → brand bg
│ ● read_file builtin read a text file [on]│
│ ○ mcp__fs__search mcp:fs semantic file search[off]│
│ … │
│ │
│ ↑↓ select · Space toggle · Esc close │
╰────────────────────────────────────────────────────────╯
| Key | Effect |
|---|---|
↑ / ↓ |
Move selection |
Space |
Toggle the selected tool on/off |
Esc |
Close |
Two-mode input-history browser, opened with Ctrl+R. It opens in browse
mode and drops into a search sub-layer on /. Enter always inserts the
selected entry into the composer for editing — it never sends.
╭──────────────────────────────────────────────────╮ browse mode
│ Input History · / to search │ (no query field)
│ │
│ 1 /repeat @hourly check for failing tests │ newest first
│ 2 how do I open the file? │
│ 3 explain this function ↵ │ ↵ = multi-line entry
│ │
│ ↑↓ navigate · / search · Tab preview · Enter insert · Esc│
╰──────────────────────────────────────────────────╯
Pressing / borrows the composer line as a live fuzzy query (the composer
draft is stashed and restored on close):
╭──────────────────────────────────────────────────╮ search mode
│ Input History ❯ open │ ← caret here
│ │
│ 1 h̲o̲w̲ do I open the file? │ best score first
│ 2 explain t̲h̲i̲s̲ function │ matched chars branded
│ │
│ type filter · ↑↓ navigate · Tab preview · Enter insert · Esc back│
╰──────────────────────────────────────────────────╯
The single source of truth for the rows is App::history_rows() — recomputed
each call, so the cursor, the list, and Enter-insert all index into the same
vector. In browse mode (or in search mode before any query) the list is
reverse-chronological — newest first. Once a query is present in search mode
the rows are the fuzzy-ranked matches, best score first, with input order as
the stable tiebreaker.
The dropdown is an extension of the composer, not an independent window: it floats anchored to the top edge of the input box and grows upward. It shares the composer's surface language — a solid panel fill with full panel-bg padding rows on the top and bottom edges, so it reads as continuous with the input box rather than a separately-bordered floating window (no left accent bar, no half-block glyphs). Its height tracks the actual row count (capped at ten entries, beyond which the body scrolls) — a short list collapses to just its rows plus the edge and header/footer rows, instead of reserving a fixed minimum. The activity bar sits directly above the composer and is always treated as above the dropdown: the panel reserves the activity bar's rows as a ceiling and never paints over them, so the live status surface stays visible even while browsing history.
| Key | Effect |
|---|---|
/ (browse) |
Enter search mode (borrow the composer line as the query) |
↑ / ↓ |
Move selection |
Tab |
Toggle a full-text preview of the selected entry |
Enter |
Insert the focused entry into the composer (browse or search) |
Ctrl+X |
Clear the entire history — arms a confirmation (y wipes, any other key cancels) |
Esc (search) |
Leave search → back to browse |
Esc (browse) |
Close the modal |
The list is the prompt text itself — there is no origin status strip
(~/project · #session… · time) under the selected row anymore; the
workspace/session stamp is still stored on each entry (it drives the inline
↑/↓ per-session recall) but is no longer displayed, since the row numbers and
text already anchor selection.
By default the history is deduplicated on the prompt text ([input_history] dedup = true): sending the same prompt twice — even in different sessions —
keeps one row, and re-sending bumps it to the top of the newest-first list.
Set dedup = false to keep per-session entries instead. /command
invocations (/model, /new, …) are not recorded by default
([input_history] record_commands = false): they are UI gestures, already
visible in the transcript, and only clutter the prompt picker. Set it to
true to make commands recallable from Ctrl+R again.
Characters whose char-index is in FuzzyMatch.positions are styled
differently (brand + bold when unselected, contrast + underlined when
selected) so the user sees why each entry surfaced. The modal is
click-outside-to-dismissable: clicking outside the panel closes it and
restores the stashed draft, exactly like a second Esc.
Centered modal for UserQuestionRequest. Presents one question at a time
with options (single- or multi-select), plus a built-in Other option
that exposes a free-text input.
Unlike other centered modals, the question modal uses the None recess policy — the surface is not dimmed or occluded and the footer is not collapsed, so the transcript, activity bar, input box, and hint bar all stay fully visible at full brightness. The modal panel simply floats on top with its own solid background.
Long text wraps automatically: the question text, option labels, and option descriptions all word-wrap to fit the modal body width.
╭────────────────────────────────────────────────────────╮
│ Question 1/2 │
│ │
│ Which framework? │
│ │
│ 1. React │ ← highlighted (single-select: no marker — the highlight is the selection)
│ component-based │ ← description (dim)
│ │
│ 2. Vue │
│ progressive │
│ │
│ 3. Other │
│ │
│ ↑↓ navigate · 1-9 jump · Enter next · Esc cancel │
╰────────────────────────────────────────────────────────╯
[x] / [ ] mark multi-select options — selection there is a separate
toggle set from the highlight, so the checkbox is the only way to tell a
selected row from a merely hovered one. Single-select shows no
marker: it is live — the highlighted row is the selection, so moving
↑/↓ or jumping with a digit immediately commits the choice and Enter
advances (or submits from the final page) with exactly what is highlighted.
There is no "Space to confirm" step. The leading
digit prefix (1.–8.) advertises the 1-9 jump shortcut. Each option's
description (when present) is rendered on its own indented line in the dim
foreground color.
| Key | Effect |
|---|---|
↑ / ↓ |
Move highlight (last row is always Other) |
1–9 |
Jump to the Nth option |
Space |
Toggle the highlighted option (multi-select only; no-op for single-select) |
Enter |
Advance to the next question; submit all answers from the final page |
Shift+Tab |
Return to the previous question, preserving per-page state |
Esc |
Settle the parked request as cancelled and close the modal |
See User questions for how the agent side blocks on the answer.
Blocking tool-permission prompt rendered inline, replacing the composer
(input-box) area; the transcript above stays visible. It is the only modal
without a backdrop or centered rect. Collapsed by default; expanding
Details grows the body upward into the transcript, up to
PERMISSION_MAX_BODY_ROWS = 14.
… transcript (visible, scrollable above) …
┃ Run shell command src/main.rs
┃ ← collapsed header
┃ Allow once Always allow Reject Details
┃ ←→ select · Enter · Esc reject ← footer band (theme.raised())
Expanded variant:
┃ Run shell command src/main.rs
┃
┃ Execute a shell command and return stdout/stderr.
┃
┃ Arguments
┃ {
┃ "cmd": "cargo test"
┃ }
┃ Allow once Always allow Reject Hide
┃ ←→ select · Enter · Esc reject · ↑↓ scroll details
A follow-up always allow until exit? confirmation flips the action set
to Confirm always · Cancel.
| Key | Effect |
|---|---|
← / → |
Move between action buttons |
Enter |
Activate the highlighted action |
Esc |
Reject (or cancel the confirm-always step) |
↑ / ↓ |
Scroll the details body (expanded only) |
The sheet uses a warn-colored left bar (panel_block(theme.warn(), …)) as
its severity cue, and theme.raised() for the footer band.
Keybindings cheat sheet. The narrowest centered modal: 58 × 70.
Opens via Ctrl+H, ? (top level, empty input), F1, or /help. Ctrl+H
is the legacy shortcut but is terminal-dependent: it is byte-identical
to Backspace (0x08), so it only opens help when the Kitty enhanced-keyboard
protocol (DISAMBIGUATE_ESCAPE_CODES) is active. Multiplexers that don't
forward Kitty flags — notably tmux, which strips the protocol on most
shipping versions — collapse Ctrl+H and Ctrl+Backspace onto the same
byte, so both keys open help there rather than Ctrl+Backspace deleting a
word (use Alt+Backspace to delete a word inside tmux). ? and F1 have
no such collision and work everywhere; prefer them inside tmux/screen. For
the full key-collision table and tmux configuration that restores the
distinction, see Terminal UI § Key collisions under tmux /
screen.
╭──────────────────────────────────────╮
│ Help │
│ │
│ General │ ← section header (fg bold)
│ enter send message │
│ … │
│ │
│ Transcript focus │
│ ctrl+↑/↓ focus a step │
│ ↑↓ cycle steps │
│ enter open the focused step │
│ esc clear the focus │
│ … │
│ │
│ esc · close │
╰──────────────────────────────────────╯
Sections: General, Line editing, Transcript focus, Views &
tools, Modes. Closes with a one-line note: Drag to select · Ctrl+C or Ctrl+Shift+C to copy.
Tabbed overview of the current round, opened by clicking the activity bar.
Two tabs cycled with ←/→:
| Tab | Contents |
|---|---|
| Activity | The current round's user prompt (wrapped) and the live status block: round N · turn M · <model> · <elapsed> + activity label + optional review alert |
| Tasks | The unified todo list: done/total header plus one row per item with a status glyph |
| Key | Effect |
|---|---|
← / → |
Cycle tabs |
↑ / ↓ |
Scroll the active tab's body |
Esc |
Close |
Transient top-right notifications rendered above all other chrome. Both
use a 3-row panel via the shared toast component, positioned at
x = term_w − toast_w − 2, y = 1, w = min(text, 58) + 2, with thick
left+right borders colored by variant.
┃ Esc again interrupts ┃
┃ ┃
┃ ┃
| Toast | Border color | Trigger |
|---|---|---|
draw_armed_toast |
theme.warn() |
An armed action awaits a second keypress (Ctrl+C to exit, Esc to interrupt) |
draw_copy_toast (success) |
theme.ok() |
Clipboard write completed |
draw_copy_toast (failure) |
theme.err() |
Clipboard write failed |
Modal-specific renderers live in crates/neenee-cli/src/tui/overlays/
(one renderer file per modal: provider, permission, history, help,
session, tools, permissions_manager, activity, tool_step_detail,
toast, plus feature-specific helpers). Shared composed pieces live in
crates/neenee-cli/src/tui/components/: modal, list, scroll,
footer, toast, and options cover the common modal shell, selectable list
body, scroll body, footer hints, notification bubble, and question option
rows. Low-level primitives (recess_backdrop, centered_rect,
modal_frame, panel_block, raw render_body) remain in
crates/neenee-cli/src/tui/primitives.rs. The chrome-hiding flag is
read by draw_transcript in crates/neenee-cli/src/tui/view.rs.