From 66c8bdaa6515440b02d7e4ca8fd5b14634d3d06c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:12:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(input):=20readline=20keymap=20=E2=80=94=20?= =?UTF-8?q?Esc-clear=20+=20Ctrl-A/E/W/K/U=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the deferred I1b readline shortcuts in the command input field (ADR-0049, closing issue #29): Esc clear a partly-typed command (only when no completion memo) Ctrl-A cursor to line start (Home alias) Ctrl-E cursor to line end (End alias) Ctrl-W delete the previous word (readline-style, UTF-8 safe) Ctrl-K kill to end of line Ctrl-U kill to start of line Esc precedence is preserved: a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022); Esc clears only when no memo is alive. While a sidebar panel is focused (Ctrl-O), Esc exits navigation mode upstream and never clears the input draft. Cursor-only keys leave history navigation intact like Home/End; buffer-mutating keys end it like Backspace. New helpers clear_input / delete_prev_word / kill_to_end / kill_to_start in src/app.rs. 22 new Tier-1 tests (2458 pass / 0 fail / 0 skip, clippy clean). ADR-0049 amends ADR-0046's OOS list; requirements.md I1b marked done. --- ...ar-navigation-and-responsive-input-hint.md | 4 +- docs/adr/0049-input-field-readline-keymap.md | 114 +++++++ docs/adr/README.md | 1 + docs/requirements.md | 12 +- src/app.rs | 319 +++++++++++++++++- 5 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0049-input-field-readline-keymap.md diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index fe10f80..a0573ad 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery). submits over a multi-logical-line buffer. DA3/DA4 keep a single logical line; this remains a separate, deferred feature. - **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred; - not touched here. + not touched here. *(Superseded 2026-06-12: I1b is now in scope and + decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input + field, issue #29.)* - **Cross-session sidebar persistence** — visibility is session-only (DB1); persisting it would amend ADR-0015. - **The output panel as a third navigation focus target** — navigation diff --git a/docs/adr/0049-input-field-readline-keymap.md b/docs/adr/0049-input-field-readline-keymap.md new file mode 100644 index 0000000..841df2b --- /dev/null +++ b/docs/adr/0049-input-field-readline-keymap.md @@ -0,0 +1,114 @@ +# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b) + +## Status + +**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29** +("Command input keystroke support") and the deferred **I1b** readline +requirement in `requirements.md`. Every fork below was escalated to the +user and user-chosen before any code was written; implemented test-first +(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean). + +This ADR **amends ADR-0046**, which explicitly listed "readline +shortcuts (I1b)" in its out-of-scope set: that item is now in scope and +decided here. It is orthogonal to ADR-0003's input-*mode* model (simple +vs advanced, the `:` sigil) — these are editing keys within the input +field, not mode or sigil changes — and it extends the single-line cursor +editing already shipped under requirement **I1a** (Left/Right/Home/End/ +Backspace/Delete, `app.rs`). + +## Context + +The input field already supported in-line cursor editing (I1a): Left/ +Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete. +Two gaps remained, raised in issue #29: + +1. No way to **clear a partly-typed command** in one keystroke — a user + who started typing the wrong thing had to hold Backspace. +2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for + keyboards without Home/End and for muscle-memory in a command-driven + workflow. This is requirement I1b, deferred by ADR-0046. + +`Esc` was free in the input field except that a *live Tab-completion +memo* consumes it first (to undo the completion in one keystroke, +ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C +(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption +toggle, ADR-0047) — none collide with a/e/w/k/u. + +## Decision + +Bind the following in the input field (non-modal, non-navigation, +both input modes), in `App::handle_key`: + +| Key | Action | +|-----------|---------------------------------------------------| +| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)| +| `Ctrl-A` | Cursor to line start (alias of Home) | +| `Ctrl-E` | Cursor to line end (alias of End) | +| `Ctrl-W` | Delete the word before the cursor | +| `Ctrl-K` | Kill from the cursor to end of line | +| `Ctrl-U` | Kill from start of line to the cursor | + +Behavioural rules: + +- **Esc precedence.** A live completion memo still wins: the first Esc + undoes the completion (ADR-0022), and Esc only *clears* when no memo + is alive. This is a natural progression — Esc once to back out the + completion, Esc again to clear. +- **Esc does not clear while navigating the sidebar.** When a sidebar + panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every + key to the navigation handler *before* the input-field keymap, where + Esc exits navigation mode (`nav_exit`). Entering nav mode never + touched the input buffer, so Esc-to-close-the-panel returns focus to + the input with the partly-typed command intact — it cannot reach the + clear binding. Locked by a regression test. +- **Single Esc clears** (user-chosen over double-Esc). Discoverable and + fast; the trade-off (an accidental Esc wipes an unsubmitted line) was + accepted. A submitted line is always recoverable from history; only + *unsubmitted* draft text is lost. +- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E, + like Home/End, move the cursor without ending history recall. +- **Buffer-mutating keys end history navigation.** Esc-clear and + Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line + *is* the new draft), matching Backspace/Delete. +- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of + trailing whitespace, then the preceding run of non-whitespace; word + boundaries are found on char boundaries so multi-byte words delete + cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W + leaves the suffix intact). + +Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`, +`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` / +`delete_before_cursor` style. + +## Forks (all user-chosen) + +- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable + over accident-proof. +- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just + the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement + in one pass rather than leaving Ctrl-W/K/U for a follow-up. +- **Documentation:** a new ADR (this one), recording the input-field + keymap convention and amending ADR-0046's OOS list — over folding it + into ADR-0046 or shipping it I1a-style with no ADR. + +## Consequences + +- I1b is complete; `requirements.md` I1b moves to `[x]`. +- The new keys are **not yet advertised on screen.** Surfacing per-focus + keybindings in the bottom status line is issue #27's domain (a + separate, in-design UX change); this ADR makes the keys *work*, #27 + will make them *discoverable*. +- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl- + chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are + glyph-less and would be invisible in an asciinema cast. Whether to add + `[CTRL-A]`…`[CTRL-U]` badges is left to ADR-0047's scope and flagged + as a follow-up — it is a cast-polish concern, not a #29 requirement. + +## Out of scope + +- On-screen keybinding hints for the input field (issue #27). +- Demo badges for the new chords (ADR-0047 follow-up; flagged above). +- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still + deferred. +- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not + requested; not part of I1b. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9389da8..9176b14 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,3 +54,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears); **Amendment 1, 2026-06-12** (issue #25): DC3's focus accent is now a **non-bold accent colour** (`theme.mode_simple`, blue) rather than bold bright-`fg` — bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player (and are fragile in some terminals), so `panel_border_style` carries no `Modifier::BOLD` on a border (bold stays fine on text spans); pure style change — the text-only Tier-2 snapshots were unaffected, the Tier-1 assertion was updated, and a render-level test now checks the focused border cells carry the accent and no bold - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed [count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch +- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank diff --git a/docs/requirements.md b/docs/requirements.md index 7b2984e..d3fc046 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -147,11 +147,19 @@ since ADR-0027.) cursor editing and is complete on its own terms; the separate **multi-line** entry goal is tracked under I1, which is genuinely not started.)* -- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E +- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E as aliases for Home / End for users on keyboards without those keys (and for ergonomics in command-driven workflows). Likely followed by Ctrl-W (delete previous word), Ctrl-K (delete to - end), Ctrl-U (delete to start). Pending. + end), Ctrl-U (delete to start). + *(Done 2026-06-12 — ADR-0049, issue #29: the full set — + Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key` + (`src/app.rs`) with helpers `clear_input` / `delete_prev_word` + / `kill_to_end` / `kill_to_start`; Esc clears only when no + completion memo is alive (the memo wins first, ADR-0022); + cursor-only keys leave history navigation intact, kill keys + end it; 22 Tier-1 tests. On-screen advertisement of these keys + is issue #27's bottom-status-line work.)* - [x] **I2** Persistent navigable input history (project-scoped). *(Implemented across Iterations 2 + 6: per-command append to `history.log` (Iter 2); on project open, the in-memory diff --git a/src/app.rs b/src/app.rs index 2863382..e9c3e74 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1217,6 +1217,13 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), + // ADR-0049 (issue #29): Esc clears a partly-typed command. + // Reached only when no completion memo is alive — the memo + // block above consumes Esc first to undo a completion. + (KeyCode::Esc, _) => { + self.clear_input(); + Vec::new() + } (KeyCode::Up, _) => { self.history_back(); Vec::new() @@ -1233,11 +1240,15 @@ impl App { self.cursor_right(); Vec::new() } - (KeyCode::Home, _) => { + // ADR-0049: Ctrl-A / Ctrl-E are readline aliases for + // Home / End — line start / end — for keyboards without + // those keys. Cursor-only, so (like Home/End) they do not + // cancel history navigation. + (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => { self.input_cursor = 0; Vec::new() } - (KeyCode::End, _) => { + (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => { self.input_cursor = self.input.len(); Vec::new() } @@ -1251,6 +1262,23 @@ impl App { self.delete_at_cursor(); Vec::new() } + // ADR-0049: readline kill shortcuts. Each mutates the + // buffer, so each ends history navigation like Backspace. + (KeyCode::Char('w'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.delete_prev_word(); + Vec::new() + } + (KeyCode::Char('k'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.kill_to_end(); + Vec::new() + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.kill_to_start(); + Vec::new() + } (KeyCode::PageUp, _) => { self.scroll_output_up(); Vec::new() @@ -1545,6 +1573,54 @@ impl App { self.input.replace_range(self.input_cursor..idx, ""); } + /// Esc — clear a partly-typed command (ADR-0049). Empties the + /// buffer, parks the cursor at the start, drops any horizontal + /// scroll, and ends history navigation (the cleared line *is* the + /// new draft). Only reached when no completion memo is alive — Esc + /// undoes a live completion first (handle_key precedence). + fn clear_input(&mut self) { + self.cancel_history_navigation(); + self.input.clear(); + self.input_cursor = 0; + self.input_scroll_offset = 0; + } + + /// Ctrl-W — delete the word before the cursor (ADR-0049). Eats any + /// run of trailing whitespace, then the preceding run of + /// non-whitespace, readline-style. UTF-8 safe: word boundaries are + /// found on char boundaries, so multi-byte words delete cleanly. + fn delete_prev_word(&mut self) { + if self.input_cursor == 0 { + return; + } + let prefix = &self.input[..self.input_cursor]; + // Strip trailing whitespace, then locate the start of the + // word that now ends the prefix. + let after_ws = prefix.trim_end_matches(char::is_whitespace); + // `idx` is the byte offset of the last whitespace char before + // the word; the word starts at the next char. No whitespace at + // all → the word starts at the buffer start. + let start = after_ws.rfind(char::is_whitespace).map_or(0, |idx| { + idx + after_ws[idx..].chars().next().map_or(0, char::len_utf8) + }); + self.input.replace_range(start..self.input_cursor, ""); + self.input_cursor = start; + } + + /// Ctrl-K — kill from the cursor to the end of the line (ADR-0049). + /// The cursor is always a char boundary, so a plain truncate is + /// safe. + fn kill_to_end(&mut self) { + self.input.truncate(self.input_cursor); + } + + /// Ctrl-U — kill from the start of the line to the cursor + /// (ADR-0049). The cursor moves to the start. + fn kill_to_start(&mut self) { + self.input.replace_range(0..self.input_cursor, ""); + self.input_cursor = 0; + } + /// Move backwards in history (towards older entries). fn history_back(&mut self) { if self.history.is_empty() { @@ -5756,6 +5832,245 @@ mod tests { assert_eq!(app.input_cursor, 0); } + // ---- ADR-0049 (issue #29): input-field readline keymap ---- + + fn ctrl(c: char) -> AppEvent { + key_mod(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + #[test] + fn esc_clears_a_partly_typed_command() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_clear_resets_horizontal_scroll() { + // A long line that has been horizontally scrolled must + // reset its scroll offset on clear, exactly like submit. + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.input_scroll_offset = 5; + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_scroll_offset, 0); + } + + #[test] + fn esc_clear_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn esc_with_live_completion_memo_undoes_rather_than_clears() { + // Precedence: while a multi-candidate Tab memo is alive, Esc + // undoes the completion (restoring the original text), it does + // NOT clear the whole input. + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); // → "show data", memo alive + assert!(app.last_completion.is_some()); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, "show "); + } + + #[test] + fn ctrl_a_moves_cursor_to_start() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.update(ctrl('a')); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_e_moves_cursor_to_end() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.update(key(KeyCode::Home)); + assert_eq!(app.input_cursor, 0); + app.update(ctrl('e')); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_w_deletes_the_previous_word() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.update(ctrl('w')); + assert_eq!(app.input, "drop table "); + assert_eq!(app.input_cursor, "drop table ".len()); + } + + #[test] + fn ctrl_w_eats_trailing_whitespace_then_the_word() { + let mut app = App::new(); + type_str(&mut app, "foo bar "); + app.update(ctrl('w')); + assert_eq!(app.input, "foo "); + assert_eq!(app.input_cursor, 4); + } + + #[test] + fn ctrl_w_at_start_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('w')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_w_only_deletes_back_to_the_cursor() { + // Mid-line: deletes the word before the cursor, leaving the + // suffix untouched. + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.input_cursor = "drop table".len(); // cursor right after "table" + app.update(ctrl('w')); + assert_eq!(app.input, "drop T"); + assert_eq!(app.input_cursor, "drop ".len()); + } + + #[test] + fn ctrl_w_handles_multibyte_words() { + let mut app = App::new(); + type_str(&mut app, "héllo wörld"); + app.update(ctrl('w')); + assert_eq!(app.input, "héllo "); + assert_eq!(app.input_cursor, "héllo ".len()); + } + + #[test] + fn ctrl_k_kills_to_end_of_line() { + let mut app = App::new(); + type_str(&mut app, "hello world"); + app.input_cursor = 5; // after "hello" + app.update(ctrl('k')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_u_kills_to_start_of_line() { + let mut app = App::new(); + type_str(&mut app, "hello world"); + app.input_cursor = 6; // after "hello " + app.update(ctrl('u')); + assert_eq!(app.input, "world"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(ctrl('u')); // cursor is at end after recall → clears all + assert_eq!(app.input, ""); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn ctrl_w_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(ctrl('w')); // deletes the recalled "A" word + assert_eq!(app.input, "drop table "); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn ctrl_w_on_whitespace_only_clears_it() { + let mut app = App::new(); + type_str(&mut app, " "); + app.update(ctrl('w')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_k_at_end_of_line_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + // Cursor at end. + app.update(ctrl('k')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_k_at_start_kills_the_whole_line() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('k')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_at_start_of_line_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('u')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_at_end_kills_the_whole_line() { + let mut app = App::new(); + type_str(&mut app, "hello"); + // Cursor at end. + app.update(ctrl('u')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_on_empty_input_is_harmless() { + let mut app = App::new(); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_exiting_nav_mode_does_not_clear_the_input() { + // ADR-0049 / ADR-0046 DC3: while a sidebar panel is focused + // (Ctrl-O), Esc exits navigation mode — the nav handler + // consumes it upstream of the input-field keymap, so the + // partly-typed command is preserved, NOT cleared. + let mut app = App::new(); + type_str(&mut app, "create table T"); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert_eq!(app.nav_focus, NavFocus::SidebarTables); + // The draft survives entering nav mode. + assert_eq!(app.input, "create table T"); + app.update(key(KeyCode::Esc)); + // Esc returned focus to the input WITHOUT clearing it. + assert_eq!(app.nav_focus, NavFocus::Input); + assert_eq!(app.input, "create table T"); + assert_eq!(app.input_cursor, "create table T".len()); + } + #[test] fn relationships_refreshed_event_updates_the_field() { // ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the