Skip to content

Latest commit

 

History

History
490 lines (414 loc) · 24.6 KB

File metadata and controls

490 lines (414 loc) · 24.6 KB

Modals

Centered overlays that take over the viewport until dismissed. Each modal declares a recess policyModal::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_factor theme 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.

Shared chrome

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 by theme.modal_dim_factor() (background stays visible); for Takeover it clears + fills with theme.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 as app_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 into header(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.

Overview

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

Closing

  • Esc or Ctrl+C closes most modals.
  • Permission sheet: Esc rejects; Ctrl+C closes and rejects.
  • Model editor: Ctrl+C restores 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().

Models modal

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.

Connections modal

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

Model editor

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 modal

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.

Tools modal

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

History search modal

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.

Question modal

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)
19 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.

Permission sheet

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.

Help modal

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.

Activity modal

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

Toasts

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

Source

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.