Files
rdbms-playground/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md
claude@clouddev1 66c8bdaa65 feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
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.
2026-06-12 22:12:08 +00:00

583 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23)
## Status
Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C**
(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470`
(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) ·
`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea
issues **#20** (hint-panel height jumpiness), **#21** (database-structure
/ left-column improvements), and **#23** (long command input). Issue
#23's own note ("handle after #21 is decided") is honoured: the input
work is split so the part that depends on the sidebar's width budget
lands with it.
Two decisions landed differently from the original draft and are
recorded inline: the relationship data lives on **`App`, not
`SchemaCache`** (DB2), and the navigation overlay clears **only the
sidebar strip + a one-column gutter** (panels stay visible behind),
not the whole area (DC2).
Builds on and honours: **ADR-0003** (the persistent Simple/Advanced
mode model — navigation mode is *not* a third input mode, see DC1),
**ADR-0027** (the input validity indicator's reserved 6 right columns —
horizontal scroll and 2-line display preserve that reserve),
**ADR-0044** (relationship visualization — the relationships panel
renders the same `RelationshipSchema` data the `show relationship`
diagram already consumes), **ADR-0013 / ADR-0043** (the
`RelationshipSchema` model: name, parent/child tables, list-based
compound columns, referential actions), **ADR-0015** (project file
format — sidebar visibility is **session-only**, so the format is
untouched), and **ADR-0002** (no engine name in user-facing strings).
Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md):
visual changes here are driven either by new `App` *state* fields
(mutated in `update()`) or by pure *render-time* functions of the frame
geometry (see State section); `update()` stays pure-sync.
**Requirements & issues touched (verified against `requirements.md`).**
Evolves **S1** (the always-present three-region layout — the left items
region becomes width-optional, DB1). **Overrides S2**, which planned
additional element kinds as *nested* items in the tables list;
relationships get their own panel instead (DB2/DB4 — see Genuine forks
§11). **Corrects S4**: its "keyboard-toggleable hint area" was never
implemented (no toggle keybinding exists in the code) and is not wanted
— the hint panel became indispensable once completion moved into it
(ADR-0022) — so the toggle phrase is struck from `requirements.md` and
no toggle is added here. Extends **I1a** (single-line cursor editing →
horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column
validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind
(DC3) does **not** regress **V4**'s output scroll, which stays live in
input mode. Adjacent but separate: Gitea **#22** (an in-app
overlay/annotation layer for casts and guided lessons — its own ADR)
shares the overlay-render and screencast context with DC2's `Clear`
overlay; the two are meant to coexist, not merge.
## Context
Three UI issues were raised together because they are coupled through
the terminal's width and height budget; treating them as one decision
avoids three conflicting partial fixes.
**Current layout (verified in `src/ui.rs`).** `render()` splits
vertically into `Min(8)` main / `Length(1)` project label / `Length(1)`
status. The main area splits horizontally into a **fixed
`Length(28)`** left column (`render_items_panel`, a "Tables" list with
indented index names) and a `Min(20)` right column. The left column's
block has `Borders::ALL`, so its **usable inner width is 26 columns**
(28 2 borders). The right column splits vertically into output
(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`).
**#20 — hint jumpiness.** `hint_content` is recomputed **every frame**
as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 35 rows. As the
user types and hint strings appear, grow, and vanish, the hint panel
resizes and **shoves the input and output panels**, producing the
flicker visible in screencasts. The root cause is that height tracks
*content* rather than terminal *geometry*.
The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the
two longest strings are `value_literal_slot` (106 chars) and
`create_table_element` (102); four more are 5057; the rest ≤ 50. The
wrapping consequence is sharp: at a right-column inner width ≥ ~54
columns the worst string needs **at most 2 lines**; a **3rd line is
only ever required when the right column is narrower than ~54** (a
sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column
terminal). On the project's screencasts (90 columns wide, sidebar
hidden — see DB1) two lines are provably sufficient.
**#21 — the left column.** A persistent 26-column Tables list is rarely
filled even half-way by a teaching database, yet it permanently costs
horizontal space the output and input panels want — acutely so on the
90-column screencasts. The pedagogical value of an *always-visible*
schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column
is **kept but made optional and more useful**, not deleted.
**#23 — long input.** The command input is a **single logical `String`**
rendered by a `Paragraph` with no wrap and no horizontal scroll
(`render_input_panel`); text past the panel width **clips silently**.
The cursor is a byte offset on a char boundary; Up/Down drive history.
The fix needs the width the sidebar's removal frees, hence the coupling.
**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion),
Enter (submit), Up/Down (history), Left/Right/Home/End (cursor),
PageUp/PageDown (output scroll), Backspace/Delete, Esc
(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred
(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input.
Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control),
Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G
(BEL). This leaves a narrow band of safe combinations for new controls.
## Decision — phasing
The work ships in three phases so the screencasts benefit from the
least-controversial part first and the riskiest part (the focus/scroll
model) is isolated:
- **Phase A — input & hint (DA1DA4):** self-contained, no sidebar
dependency; fixes #20 and the baseline of #23.
- **Phase B — optional, richer sidebar (DB1DB3):** visibility model +
relationships panel + schema-cache enrichment.
- **Phase C — navigation mode (DC1DC4):** the Ctrl-O focus/scroll/
expand model that makes the sidebar browsable.
Each phase is independently shippable and independently green.
## Decision — Phase A: responsive input & hint heights
### DA1 — Hint height is a function of terminal geometry, fixed between resizes
The hint panel's height is **decoupled from hint content**. It is
computed from the terminal's width and height **once per resize** and
held constant as the user types. Because the panel no longer resizes on
every keystroke, it never shoves the input/output panels — the #20 jump
is eliminated at the source, not damped. Content that exceeds the fixed
height is ellipsized (the existing `clamp_wrapped` truncation), which is
now a rare, width-driven event rather than a per-keystroke one.
### DA2 — Responsive height buckets
Heights are chosen by terminal **height** (rows), with the hint's
optional 3rd line gated on right-column **width** (per the Context
measurement):
| Terminal height | Input content rows | Hint content rows |
| --- | --- | --- |
| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 |
| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) |
A safety degradation protects tiny terminals: the output panel's
`Min(5)` is honoured first; if rows are insufficient, the hint shrinks
to 1, then the input to 1. The `40`-row threshold is a tunable constant.
### DA3 — Input horizontal scroll (single logical line)
The input keeps its **single-`String`** model (no embedded newlines —
this is explicitly *not* multi-line input, see Out of scope). A new
`App` field `input_scroll_offset: usize` tracks the first visible
column; the renderer shows a window of the line and keeps the cursor in
view, mirroring the candidate-line horizontal-scroll markers already in
`render_candidate_line`. The ADR-0027 6-column indicator reserve is
preserved (the scroll window is the text area = `inner.width 6`, not
the full inner width). Because `update()` does not know the panel width,
the renderer feeds it back via a `note_input_viewport(text_width)` call
(the analogue of the existing `note_output_viewport`), against which the
offset is clamped to keep the cursor visible. `input_scroll_offset`
**resets to 0** whenever the buffer is replaced wholesale — on `submit`,
on history navigation (Up/Down), and on any clear. This is the baseline
#23 fix and is sufficient on its own for the compact (1-row) layout.
### DA4 — Two-line input display when tall (`H ≥ 40`)
On comfortable terminals the input renders across **2 visual rows** by
soft-wrapping the single logical line, with the cursor mapped to a
(row, col) within the two rows. Content longer than two rows scrolls
the two-row window horizontally (DA3) so the cursor stays visible. The
**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge
of the *first* row** (its 6-column reserve applies to row 1; the soft-
wrap on row 1 stops 6 columns short, row 2 uses the full text width) —
S6 is preserved.
This is display-only over the same single-`String` model — distinct
from the deferred true multi-line-input feature (I1, which adds
*multiple logical lines* with Enter-inserts-newline). **Forward-compat
note:** I1, when built, should reuse DA4's row-rendering and cursor
(row, col) mapping rather than introduce a parallel one — DA4 is the
substrate, not a competitor.
## Decision — Phase B: optional, richer sidebar
### DB1 — Width-derived visibility plus transient peek (session-only)
Sidebar visibility is **derived, not stored**: the sidebar is visible
iff the terminal **width > 90** *or* navigation mode is currently
focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed
every frame from terminal width and `NavFocus`; nothing persists to
`project.yaml` (ADR-0015 untouched), so it is session-only by
construction — and there is no stored visibility field to keep in sync.
At ≤ 90 columns the sidebar is hidden by default — so the 90-column
screencasts never show it and the output panel gets the full width it
needs there — but `Ctrl-O` temporarily reveals it for the duration of a
browse and re-hides it on exit (DC1).
**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue
#21's original wording asked for "a keystroke to show and hide it"; the
Ctrl-O peek covers that need, so no separate toggle and no
force-shown/force-hidden override is added. Visibility stays a pure
function of `(terminal width, NavFocus)` — the simplest model that
satisfies the requirement. Should pinning ever prove necessary, a
persistent override is an additive follow-up (see Out of scope).
### DB2 — Add a relationships panel; enrich the schema cache
The left column gains a **second panel** below Tables: a list of the
project's relationships. This is a deliberate **override of S2**, whose
note proposed additional element kinds (relations, views) as *nested*
items inside the existing tables list. Relationships are *cross-table*,
not per-table, so nesting them under a single table reads wrong; a
sibling panel is the honest shape (user-confirmed 2026-06-10). S2's
"without restructuring" intent is still met — the items column simply
holds two stacked panels (DB4) instead of one.
The panel needs the full `RelationshipSchema` (name, parent/child
tables, list-based columns, on-delete/on-update actions) that the `show
relationship` path already fetches.
**Data home — `App`, not `SchemaCache` (revised at implementation,
2026-06-10).** The design first proposed an additive
`SchemaCache.relationship_details: Vec<RelationshipSchema>` field.
Implementation revised this to a **parallel `App.relationships:
Vec<RelationshipSchema>`** field for two reasons: (1) `SchemaCache` is
*walker/completion-facing* — it needs only relationship **names**
(unchanged in `SchemaCache.relationships`, still borrowed as
`&Vec<String>` by `IdentSource::Relationships`); the full records are
**UI-only**, so `App` is the architecturally correct home, mirroring
`app.tables` (which the items panel already reads alongside the cache).
(2) Adding a field to `SchemaCache` would force edits to ~23 full
struct literals across the test suite, whereas `App` gains one field.
The /runda guard it answered — *don't break completion by retyping
`relationships`* — is fully honoured either way. Delivery: a worker
`Request::ReadAllRelationships` (→ `Database::read_all_relationships`,
returning `Vec<RelationshipSchema>` via the existing
`read_all_relationships(conn)`); the runtime's `refresh_schema_cache`
posts a new `AppEvent::RelationshipsRefreshed` alongside
`SchemaCacheRefreshed`, and the `App` stores it. No behavioural
difference from the original design.
The panel has **two display states** keyed off focus (DC2):
- **Unfocused (26-col)** — an ambient glance. Per relationship: the
name (ellipsized past the inner width), and the endpoints broken at
the arrow to fit a narrow column:
```
Customers_Orders
Customers.id ->
Orders.customer_id
```
- **Focused + expanded (4050 col, DC2)** — a browse view. At the wider
width the endpoints fit on one line
(`Customers.id -> Orders.customer_id`); the arrow-break is used only
when even the expanded width cannot hold a (possibly compound)
endpoint pair. The wider width minimises horizontal truncation so the
panel needs **mainly vertical scrolling** (DC3).
### DB3 — Sidebar width unchanged when unfocused
The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening
happens only on focus (DC2), as an overlay, so the unfocused layout and
the right-column reflow are unchanged from today.
### DB4 — Vertical split of the two left-column panels
The items column stacks **Tables (top)** and **Relationships (bottom)**.
The Relationships panel's height is content-driven within bounds, so it
stays small when there is little to show and never dominates the column
(user-chosen 2026-06-10):
- **No relationships:** fixed at **5 rows** (3 content + 2 border),
rendering a single `None` line. This is the floor.
- **With relationships:** grows with content (`content_rows + 2`, where
the unfocused format is ~3 rows per relationship) up to a **cap of
50 % of the column height**; beyond the cap the panel **scrolls**
(DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`.
- **Tables** takes the remainder (`col_h rel_h`) and scrolls if it
overflows (it, too, is a focusable, scrollable panel — DC3).
- **Degradation:** on a column too short to honour the 5-row floor plus
a usable Tables panel (`col_h < ~10`), the floor yields first so
Tables keeps at least its border + one row; both panels stay
renderable. The `50 %` cap and `5`-row floor are tunable constants.
Heights are a pure render-time function of the column height and the
cached relationship count, so they are unit-testable without a terminal
(see Testing).
## Decision — Phase C: navigation mode
### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode
`Ctrl-O` enters a **navigation mode** that is orthogonal to the
Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes
go*, not *how commands parse*. It drives a focus cycle:
1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if
it is currently hidden — a temporary peek).
2. **Press 2 →** focus the **Relationships** panel.
3. **Press 3 →** leave navigation mode: restore the sidebar width,
re-hide it if the peek revealed it, and return focus to the command
input.
`Esc` exits navigation mode directly from any focused panel (a
short-cut for step 3); `Esc` is otherwise only completion-undo, which
does not apply while browsing.
**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix*
and `Ctrl-A` is *screen's* — a multiplexer eats them before the app
sees them, so either would make navigation mode unreachable for the many
students who run inside tmux/screen. `Ctrl-O` is not a multiplexer
prefix; in the raw mode the TUI sets, its legacy line-discipline meaning
(discard-output) is disabled, so it reaches the app. It is free in the
app today (the main key handler's catch-all, `app.rs:1001`). The
mnemonic is weak ("**O**utline"); reachability won over mnemonic.
**Routing.** Navigation mode is handled inside the **main** key handler,
which runs only when no modal is open (`app.rs:919` gates on
`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while
a modal dialog is active** — modals keep full keyboard ownership. Within
the main handler, a `NavFocus != Input` branch precedes the normal
input-editing arms and routes keys per DC3/DC4.
### DC2 — Expand-on-focus as an overlay
A focused sidebar panel widens to a **45-column** overlay
(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded
panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and
paints the wide panel on top. The output/input/hint panels underneath
keep their exact layout — **unused and unchanging** while browsing,
**still visible to the right** of the overlay (just partially occluded
on the left) — and are restored fully by the next frame on exit. The
gutter keeps them from butting against the expanded panel's border so
the overlay edge reads cleanly. This is cheap because the renderer is a
pure function of `App` state: focus state selects the width and the
overlay path. (The input underneath is inactive in navigation mode.)
*Implementation note (2026-06-10):* a full-area clear (hiding the base
panels entirely during browse) was tried first and rejected — leaving
the base visible is truer to "underneath keep their layout," and the
one-column gutter resolves the only wrinkle (the panels' left edges
being cut by the overlay reading harshly without separation).
### DC3 — Scroll the focused panel; focus highlight
While a sidebar panel is focused it scrolls, reusing the output panel's
proven mechanism (a `usize` offset clamped against a renderer-reported
viewport via a `note_*_viewport` call):
- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel;
user-chosen 2026-06-10).
- **PageUp / PageDown — page** scroll.
This is a context-sensitive rebind: Up/Down drive *history* and
PageUp/PageDown scroll the *output* in input mode, whereas in navigation
mode they scroll the *focused sidebar panel*. The two contexts never
apply simultaneously (`NavFocus` selects which). The focused panel shows
an **accent border** so it is obvious where keys are going (lazygit
convention).
### DC4 — Other keys are inert in navigation mode
The command input is visibly occluded by the overlay while browsing, so
keys that have no navigation meaning are **inert** rather than acting on
the hidden input. Specifically, **only** `Ctrl-O` (advance focus),
Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live;
printable characters, Enter, Tab, Backspace/Delete, Left/Right, and
Home/End all do nothing until navigation mode is exited. The occlusion
signals "not typing," so swallowing these is clearer than letting them
silently edit an invisible buffer.
## State, keybindings, and cross-cutting wiring
**Stored `App` state** (mutated in `update()`, read by the renderer):
- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav /
clear.
- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the
navigation-mode focus cursor; `Input` ≙ not in navigation mode.
- Per-panel scroll offsets for the Tables and Relationships panels, each
clamped against a renderer-reported viewport (DC3), mirroring
`output_scroll` / `note_output_viewport`.
- **`App.relationships: Vec<RelationshipSchema>`** (DB2) — the full
relationship records for the sidebar panel, delivered by
`AppEvent::RelationshipsRefreshed` from the runtime's schema refresh.
`SchemaCache.relationships: Vec<String>` (names, for completion) is
unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.)
**Render-time derived** (pure functions of `frame.area()` + cached
counts — *not* stored fields; this keeps the pure-render invariant and
makes the geometry logic unit-testable without a terminal):
- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel`
(DB1).
- Input/hint row counts — a pure helper `panel_heights(area) ->
(input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and
the Tier-1 tests call.
- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)`
(DB4).
- Input width fed back to `update()` via `note_input_viewport`
(DA3), since `update()` cannot read `frame.area()`.
Keybindings introduced/affected:
| Key | Input mode | Navigation mode |
| --- | --- | --- |
| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) |
| `Up` / `Down` | history (unchanged) | line-scroll focused panel |
| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel |
| `Esc` | completion-undo (unchanged) | exit nav mode directly |
| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert |
All nav keys are inert while a modal is open (the main handler is gated
on `!modal.is_some()`, `app.rs:919`).
Renderer changes (`src/ui.rs`): geometry-driven hint/input height
(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap
with row-1 indicator (DA4), the relationships panel + two-panel split
(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay
(DC2/DC3); `note_input_viewport` feedback added alongside the existing
`note_output_viewport`.
## Genuine forks (escalated, resolved 2026-06-10)
1. **Left column fate** — remove entirely vs narrow vs **keep + make
optional and richer** (chosen, user). → DB1/DB2.
2. **Focus/scroll model** — a navigation mode (chosen, user) vs
modeless modifier-key scroll vs deferring scroll. → DC1.
3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B`
*rejected on review* (it is the default tmux prefix → unreachable
inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded.
→ DC1.
4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen,
keeps the right panels unchanging) vs re-splitting the layout (would
reflow output). → DC2.
5. **Navigation-mode printables** — **ignore** (chosen, user) vs
drop-to-input-and-type. → DC4.
6. **Hint anti-jump** — **fix height to terminal geometry** (chosen)
vs damping/hysteresis vs always-reserve-max. → DA1.
7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with
1/2 and 2/2 splits (chosen, user). → DA2.
8. **Visibility persistence** — **session-only** (chosen, user) vs
per-project in `project.yaml`. → DB1.
9. **Persistent show/hide toggle** — **deferred** (chosen, user): the
Ctrl-O peek covers #21's "keystroke to show and hide", so visibility
stays width-derived with no override. → DB1.
10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen,
user) vs leaving scroll to PageUp/PageDown only. → DC3.
11. **Relationships placement** — **a separate sibling panel** (chosen,
user — *overrides S2*) vs nesting relations inside the tables list
per S2's documented extension model. → DB2/DB4.
12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint
panel is indispensable since completion moved into it; S4's stale
"keyboard-toggleable" claim (never implemented) is struck from
`requirements.md`. → Status (Requirements & issues touched).
## Testing
Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta`
snapshots, `src/snapshots/`) for the visual surfaces** — this change is
heavily render-side, so the geometry/format/overlay assertions belong in
snapshots, not only behavioural tests — and Tier-3 integration.
Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the
DB4 split, visibility) are **pure functions** exercised directly in
Tier-1 without a terminal.
Phase A:
- **Hint anti-jump:** `panel_heights(area)` is invariant under changing
hint content at a fixed terminal size (assert it does not change as
`app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40`
boundary and the width-< 54 boundary.
- **Height buckets:** compact → input 1 row / hint 2; comfortable →
input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal
degradation honours output `Min(5)`.
- **Input horizontal scroll:** a line longer than the panel keeps the
cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column
reserve is intact; no characters are lost (buffer = full string);
`input_scroll_offset` resets on submit / history-nav / clear.
- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders
both rows with correct cursor (row, col), the `[ERR]`/`[WRN]`
indicator on row 1's right edge (Tier-2 snapshot); a longer line
scrolls.
Phase B:
- **Relationship data path:** `Database::read_all_relationships`
returns full records through the worker thread (integration test, real
DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates
`App.relationships`; `SchemaCache.relationships` names are undisturbed
(completion still resolves them).
- **Relationships panel render (Tier-2):** empty → a single `None` line
at the 5-row floor; the unfocused narrow format (name + arrow-break,
ellipsis past inner width); a compound endpoint pair arrow-breaks
correctly.
- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5,
⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %;
Tables takes the remainder; degrades sanely at `col_h < 10`.
- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed
on resize (the peek interaction is covered under Phase C).
Phase C:
- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships →
Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit;
a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert
while a modal is open.
- **Expand overlay (Tier-2):** focusing widens to the expanded width;
the underlying output/input/hint state is unchanged across enter/exit
(no reflow); the focus accent border marks the focused panel.
- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown
page-scroll the focused panel (clamped to its viewport); in input mode
Up/Down still drive history and PageUp/PageDown still scroll output
(no V4 regression); inert keys (printable/Enter/Tab/Backspace) do
nothing in nav mode.
All tiers green, zero skips; clippy clean (nursery).
## Out of scope
- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter-
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. *(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
mode cycles the two sidebar panels only; output keeps its input-mode
PageUp/PageDown scroll.
- **Relationship search / filtering within the panel** — the panel is a
scrollable list; no query box.
- **Relationship rename / edit from the panel** — it is read-only;
mutation stays with the DSL/SQL commands.
- **A persistent show/hide toggle / force-shown override** (DB1,
resolved deferred) — visibility is width-derived + Ctrl-O peek; a
pin/force override is an additive follow-up if ever needed.
- **A hint-area toggle (old S4 wording)** — not implemented today and
not wanted (the hint panel is indispensable since completion moved in;
fork §12). The stale "keyboard-toggleable" phrase is removed from S4.
- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a
separate feature with its own ADR; DC2's `Clear` overlay is built to
coexist with it, not to provide it.
## Accepted consequences
- **Width-threshold discontinuity.** Because `Auto` visibility flips at
width 90 and the sidebar costs 28 columns, widening a terminal across
the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner)
as the sidebar appears. This is inherent to any width-gated auto-hide
and is accepted: 90 is the screencast width, real terminals sit well
to one side of it, and `Ctrl-O` peek covers the in-between case. The
`90` threshold is a tunable constant.
## Amendment 1 — focus accent is a colour, not bold (2026-06-12)
Issue #25. DC3's "accent border" on the focused sidebar panel was
first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on
the box-drawing border. Bold box-drawing glyphs render as broken /
gapped line-art in the asciinema player used for the website casts
(vertical strokes don't connect to the corner glyphs) and are
fragile in some terminals.
**`panel_border_style` now marks focus with a non-bold accent
colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on
a border.** The unfocused border stays muted `theme.border`. This
makes the ADR's "accent border (lazygit convention)" wording
literal — it is now a true accent hue rather than bold bright-fg —
and is what renders cleanly in casts. Bold remains fine on *text*
spans (titles, key hints); the constraint is specifically that
box-drawing borders carry no bold attribute.
Note: this is a pure style change. The Tier-2 snapshots are
text-only (`render_to_string` captures cell symbols, not styles),
so none needed re-accepting; the Tier-1 `panel_border_style`
assertion was updated and a render-level test now checks the actual
border cells carry the accent colour and no bold.