TUI Design Decisions
This document is the canonical record of every binding TUI design decision — navigation conventions, focusability rules, component reuse contracts, color palette, modal sizing, scroll semantics, and more. Read it before implementing any TUI change. When a new decision is made, add it here immediately.
If a proposed implementation conflicts with a rule here, fix the implementation, not the rule (or open a discussion to amend the rule first). Violations are bugs and must be fixed before a PR lands.
Every PR review that touches a TUI surface must verify the diff against this page before producing review output. Reviewers must call out any mismatch as a TUI design-decision violation, especially missing clickable affordances, missing footer hints, focusability drift, or layout geometry that diverges from the shared helpers.
Navigation conventions
Section titled “Navigation conventions”jackin’ TUI follows the W3C ARIA Tabs interaction pattern for all tabbed interfaces and the corresponding area-scoped arrow-key model everywhere else.
Key roles
Section titled “Key roles”Tab / BackTab are the designated cross-area navigation keys. They move focus between logical areas: from the field area to the button row, back from the button row to the field area, and between a tab list and its tab panel (content area). Tab always moves forward in the cycle; BackTab always moves backward.
Left / Right are intra-area horizontal keys. They move focus within the current horizontal group (e.g. between buttons in a button row, or between tabs when the tab list has focus). They must never jump between distinct areas.
Up / Down (and j / k aliases) are intra-area vertical keys. They move focus within the current vertical field group. They must never cross the boundary from a field area into the button area or vice versa.
W3C Tabs pattern (required for all tabbed interfaces)
Section titled “W3C Tabs pattern (required for all tabbed interfaces)”All tabbed surfaces (workspace editor, settings) must implement the W3C tablist/tabpanel pattern:
-
Tab list (the row of tab labels) is its own focus area:
←/→cycle between tabs.Tabor↓moves focus into the first block inside the tab panel (content area).- The active tab is visually distinguished even without tab-list focus; when the tab list IS focused, an additional highlight (e.g. bold or underline) makes this clear.
-
Tab panel (the content below the tabs) may contain one or more blocks:
↑/↓navigate within the focused block.Tabadvances to the next block within the panel; after the last blockTabcycles back to the tab list.BackTab/Escreturns focus to the tab list.
-
Entry into a tabbed stage (opening Settings, opening the workspace editor) must start with tab-list focus — the operator sees the tab labels highlighted and uses
←/→to pick a tab beforeTab/↓drops them into the content.
This rule applies to every tabbed surface that ships; add a tab_bar_focused: bool field to the relevant state struct and update input dispatch and rendering accordingly whenever a new tabbed surface is added.
Other area-boundary rules
Section titled “Other area-boundary rules”- Down from the last field in a form’s field area is a no-op. Tab is the key that crosses into the button area.
- Up from the first button in the button area is a no-op. BackTab is the key that crosses back into the field area.
Exception — tree header expand/collapse: Right = expand and Left = collapse on tree-style header rows (Secrets AgentHeader, Auth RoleHeader, Environments RoleHeader, and workspace manager list rows) is a correct intra-area semantic action. This absorption pattern is intentional and must be preserved. For the workspace manager list specifically: → on a workspace row expands it to reveal instance child rows; ← on an instance row collapses the parent workspace and moves the cursor to it; ← on an already-expanded workspace row collapses it in place. The h/l bindings are reserved for horizontal scrolling of the details pane and must not be aliased to expand/collapse.
Exception — flat button-strip widgets: Self-contained single-row button widgets (confirm.rs, save_discard.rs, mount_dst_choice.rs, source_picker.rs, scope_picker.rs) may treat Left / Right as BackTab / Tab equivalents. The entire widget is a single flat area with no area boundary to cross, so there is no violation.
Navigation hints (exhaustive — no hidden keys)
Section titled “Navigation hints (exhaustive — no hidden keys)”Every keyboard shortcut that is active on the current screen must appear in the footer hint bar. No key may be silently available — users must never have to guess. The hint bar updates whenever the focused area or row context changes.
Rules:
↑/↓ navigatemust appear whenever a list, table, or form with multiple rows is active. It is a global footer item, not a conditional one.- Every action key (
Enter,Space,D,A,R,N,1,2,3,O,P,M, etc.) that is live on the current row must be shown alongside its one-word description. - When a key applies only under certain conditions (e.g.
O open in GitHubonly for GitHub-origin mounts), show it only when the condition holds — but never suppress a key that is unconditionally active. Tab switch tab,S save, andEsc back/discardare always shown in the global footer.- Keep each hint concise: one symbol or key name, one word label. Use
·separators between hints.
Clickable targets must look clickable
Section titled “Clickable targets must look clickable”Any jackin’ TUI element that performs an action on click must expose three matching cues: a distinct resting style, a hover style change, and pointer-shape feedback where the terminal supports it. The hover change must be visible but modest — a lifted background, brighter foreground, or equivalent cue that matches the website’s “this is interactive” clarity without overpowering the surrounding chrome. The visual target must be the exact value or control that will act. For copy dialogs, only the value copied by Enter is rendered as the emphasized clickable value; informational rows stay in the normal value color.
When a footer says Enter copy <value>, clicking that same emphasized value must perform the same copy action and show visible feedback. Do not make a whole dialog row look actionable if only one value is copied, and do not add click handlers without a visible hover/resting affordance.
This rule applies to every TUI surface: list view, workspace editor (all tabs), settings (all tabs), all modals and overlays.
Space vs Enter — W3C semantic distinction
Section titled “Space vs Enter — W3C semantic distinction”Space is the key for toggle semantics (on/off, checked/unchecked, trusted/untrusted, allowed/disallowed). It matches the W3C ARIA patterns for checkbox, switch, and radio group. Never bind Enter to the same action as Space on a toggle widget.
Enter is the key for action semantics (activate a button, open a dialog, confirm a choice, navigate into detail, submit a form). It is also the key for shortcut-letter bindings (Y yes, N no, S save, D discard, etc.) on confirmation dialogs.
Binding both Space and Enter to the same toggle action is a W3C violation — Space is sufficient and Enter would collide with its role as the action key. Binding both to a button widget is acceptable (W3C requires both on standard buttons), but jackin’ button-strip widgets use letter shortcuts + Enter to avoid this; don’t add Space to them.
Summary:
- Toggle row (keep_awake, git_pull, trusted, allowed role):
Spaceonly. - Action button (Save, Cancel, Discard, Yes, No):
Enteronly (plus letter shortcut if applicable). - Open/navigate (expand detail, open picker, rename):
Enteronly. - Cycle among options in a slot (auth mode):
Space(radio-button pattern per W3C). - Re-open a picker for an already-set value (1Password op-ref, source):
Enter(action).Pis also supported as a shortcut where op_available is true, butEntermust always work.
Hints: footer only, never inside dialogs
Section titled “Hints: footer only, never inside dialogs”All keyboard hint text belongs in the footer bar at the bottom of the screen. Dialogs, modals, and overlays must not render their own internal hint line. When a modal is open, the main footer must show the modal’s available keys instead of the keys for the content behind it.
Enforcement:
- Every widget in
src/console/widgets/must expose afooter_itemsfunction (or equivalent) and remove its internal hint row. render_editorchecksstate.modalfirst; if a modal is open it callsmodal_footer_items(modal)and skips the normal contextual items.render_settingschecks the three settings modal chains (auth.modal,env.modal,mounts.modal) in priority order; if any isSome, calls the corresponding*_modal_footer_itemsfunction.- No
Constraint::Lengthrow for a hint line may remain inside any widget layout.
Modals dim the full surface behind them
Section titled “Modals dim the full surface behind them”Every modal or dialog is global to the TUI surface, not scoped to one pane, tab, list row, or content block. Opening a modal must redraw the whole background in backdrop colors before painting the modal: top chrome, bottom chrome, pane/list/card borders, focused-state accents, scrollbars, and content all dim together. Do not leave active green borders, selected scrollbars, or bright clickable chrome visible behind a modal. Those focus cues belong to the surface behind the dialog and return only after the modal closes.
Focusability
Section titled “Focusability”A block is focusable only when its content overflows the visible area in at least one axis (horizontal or vertical).
When a block’s content fits entirely within its viewport, clicking it must not show the green border and keyboard scroll keys must not change its scroll offset. A block that is not scrollable in any direction has nothing to communicate with a focus highlight, so showing one is actively misleading — the green border tells the operator “you can scroll here” and the operator will try, get no response, and lose trust in the UI.
Why this matters
Section titled “Why this matters”The green border is the affordance for scroll. Operators use it as a signal: click to focus, then use H/L/Up/Down/scroll-wheel to navigate content that doesn’t fit. If a block highlights green but has no overflow, the signal is a lie. Every interaction after that — pressing scroll keys, watching nothing happen — erodes trust in the UI conventions.
Implementation
Section titled “Implementation”render_scrollable_block enforces the rule automatically: it computes content_width, content_height, viewport_w, and viewport_h from the lines it is about to render, checks is_scrollable(content_width, viewport_w) || is_scrollable(content_height, viewport_h), and ignores the focused flag when the result is false. Callers pass focused = true or false from their state; the renderer suppresses the green border if the content currently fits.
This means the rule is impossible to accidentally violate through the shared renderer. What callers must still ensure:
-
Mouse click handler (
update_scroll_focus) — set thefocused/scroll_focusedstate field for any block whose hit-test rect contains the click, regardless of whether the block is currently scrollable. The renderer already suppresses the green border when content fits; the state field only needs to be accurate for keyboard scroll dispatch (if the block isn’t scrollable, scroll keys are no-ops anyway). Do NOT addis_scrollableguards in the click handler — that causes a different bug where clicking a non-scrollable block silently clears focus from the previously focused block without giving it to the clicked one. -
Resize guard — when the terminal shrinks and content that previously overflowed now fits,
list_scroll_focus(list view) is cleared byfocused_block_still_scrollable. Similar guards must exist for any persistent focus state that a terminal resize can invalidate.
Both checks must use the same content height and width values as render_scrollable_block uses for the actual rendered lines. Any mismatch causes the bug where the input handler clamps scroll to the wrong maximum — the scrollbar thumb stops short of the track end even though the operator has not reached the actual content limit.
Click hit-test area must cover the full rendered block
Section titled “Click hit-test area must cover the full rendered block”The mouse hit-test rect for a scrollable block must exactly match (or be a superset of) the area passed to render_scrollable_block. Never compute the hit-test area from content dimensions or row counts — use the full rendered block height.
Why this matters: A block that holds N content rows plus an empty trailing area is still one block. The empty rows are inside the border; they are visually part of the same scrollable surface. Operators intuitively click anywhere inside the block — including blank space below the last row — and expect focus to switch. If the hit-test rect is computed from content height alone (e.g. height = rows + 2), clicks in the empty trailing area fall outside the rect and silently do nothing. The operator sees their click ignored and infers the UI is broken.
Implementation rule: Every point_in(mouse, area) check in update_scroll_focus must use the same area that the renderer passes to render_scrollable_block. In practice this means:
- For full-body blocks (editor Mounts tab, editor other tabs, settings content): use
editor_content_area(term_size)— the full body rect below header + tab strip. Do not shrink this to content height. - For per-block sub-areas in the list view right pane (Workspace mounts, Global mounts, Roles): use the height computed by the same block-height function the renderer’s
Constraint::Length(...)uses.
In mouse.rs, editor_scroll_area wraps editor_content_area to guarantee they stay in sync — editor_scroll_area must never compute its own area dimensions independently.
A violated hit-test rect is a silent bug: no compile error, no test failure, and no obvious visual artifact — the block just refuses to focus when clicked in the empty trailing area.
Content height formula for each block type
Section titled “Content height formula for each block type”These are the exact formulas that match what each render function passes to render_scrollable_block:
| Block | content_height |
|---|---|
| Workspace mounts | 1 + max(data_rows, 1) where data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise) |
| Global mounts / Role global mounts | 1 + data_rows where data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise); empty sections render one placeholder row |
| Roles | 2 + agent_count (1 “Default …” row + 1 blank row + agent rows) |
| Left sidebar (workspace names) | no vertical scroll — use clamp_list_scroll_for_area horizontal-only path |
The workspace mounts formula uses .max(1) because an empty mount list renders a “(none)” placeholder row, so even zero mounts produce 1 data row.
Scrollable Blocks — Shared Component
Section titled “Scrollable Blocks — Shared Component”All bordered scrollable content blocks must use render_scrollable_block from render/mod.rs. Never hand-roll block creation, Paragraph scroll, or scrollbar rendering inline.
render_scrollable_block guarantees:
- Border color
PHOSPHOR_GREENwhen focused,PHOSPHOR_DARKotherwise. - Horizontal scrollbar rendered only when content overflows horizontally.
- Vertical scrollbar rendered only when content overflows vertically.
*scroll_xand*scroll_yclamped to valid range in-place every frame (eliminates stale overshoot).- Scrollbar thumbs are proportional to visible content in both axes, so small overflow produces a long thumb and large overflow produces a short thumb, while a block scrolled to its maximum offset still shows the thumb at the visible end of the track.
- Scrollbar thumb length is invariant for a fixed
content_length+viewport+ track length. Scrolling moves the thumb; it must never resize by one cell because of offset-dependent rounding. - Horizontal scrolling preserves matching trailing padding for each row’s leading padding, so two-space-indented tables do not pin their final visible character against the right border or scrollbar column at maximum horizontal offset.
Input hit-testing, focusability, cursor-follow scrolling, selected-list windowing, scrollbar dragging, wheel/key max-offset math, right-padding content width, and scrollbar rendering must use the shared scroll helpers in src/console/widgets/scrollable.rs (viewport_width, viewport_height, is_scrollable, max_offset, effective_offset, cursor_follow_offset, render_selected_lines_in_area, render_lines_with_offset_in_area, horizontal_scrollbar_area, vertical_scrollbar_area, scrollbar_position_for_offset, scrollbar_offset_for_track_position, apply_scroll_delta, and apply_horizontal_scroll_delta). Do not reimplement area.width - 2, area.height - 2, content - viewport, cursor-follow viewport offsets, selected-list slicing, proportional thumb sizing, trailing scroll padding, scrollbar hit areas, track-position formulas, or wheel/key offset math in input handlers or widget renderers; drift between input geometry and renderer geometry is a scroll bug. Do not delegate scrollbar thumb geometry to Ratatui’s ScrollbarState: its thumb size is computed via floating-point division from content_length, so passing a wrong content_length produces an offset-dependent size; and even with the correct argument, independent rounding of thumb start and end can still produce a one-cell resize at the boundaries.
Signature:
pub(crate) fn render_scrollable_block( frame: &mut Frame, area: Rect, lines: Vec<Line<'_>>, scroll_x: &mut u16, scroll_y: &mut u16, focused: bool, title: Option<&str>,)Pass None for title to suppress the block title. Pass Some(" Title ") (with surrounding spaces) to match the standard label style.
Focus Persistence
Section titled “Focus Persistence”list_names_focused (left sidebar) is cleared only when:
- The user clicks in the right pane —
update_scroll_focussets it tofalse. - The terminal resizes so the content fits —
clamp_list_scroll_for_areasets it tofalse.
It must not be cleared by reset_list_scroll. Workspace selection changes keep the user in the left pane; the focus state should persist.
list_scroll_focus (right-pane blocks) is cleared when:
- Content no longer overflows after resize —
focused_block_still_scrollablereturnsfalse. - User clicks left pane —
update_scroll_focusclears it. - Workspace changes —
reset_list_scrollsets it toNone.
Mouse Scroll (Hover-Scroll)
Section titled “Mouse Scroll (Hover-Scroll)”Mouse ScrollLeft/ScrollRight scroll whichever block the cursor is currently over without requiring a prior click. This is hover-scroll semantics, not click-then-scroll.
In scroll_active_panel, dispatch order for the list stage:
- If
list_names_focused→ scrolllist_names_scroll_x(left pane). - Otherwise → compute
list_scroll_areas, route to the block under the pointer.
The left pane scroll uses apply_scroll_delta (no clamping to content width) because render_scrollable_block clamps on every frame anyway.
PHOSPHOR Color Palette
Section titled “PHOSPHOR Color Palette”Canonical definitions live in src/console/widgets/mod.rs. All other files use pub(super) use re-exports from render/mod.rs. Never define local constants.
| Constant | Value | Usage |
|---|---|---|
PHOSPHOR_GREEN | Rgb(0, 255, 65) | Active/focused elements, selected text |
PHOSPHOR_DIM | Rgb(0, 140, 30) | Inactive text, scrollbar thumbs |
PHOSPHOR_DARK | Rgb(0, 80, 18) | Borders (unfocused), separators |
WHITE | Rgb(255, 255, 255) | Labels, keys, headings |
Block Border Colors
Section titled “Block Border Colors”- Focused and scrollable:
PHOSPHOR_GREENborder — bothfocused = trueAND content overflows in at least one axis. - Focused but not scrollable:
PHOSPHOR_DARKborder —focused = trueis ignored when content fits; no false affordance. - Unfocused:
PHOSPHOR_DARKborder regardless of scrollability.
render_scrollable_block enforces this logic internally. Callers only supply focused: bool from state; the renderer decides whether to use green. Never set border style outside render_scrollable_block.
Left Sidebar (Workspace Name List)
Section titled “Left Sidebar (Workspace Name List)”- Horizontal scroll only — no vertical scroll.
- Uses
render_scrollable_blockwithscroll_y = 0u16(a throwaway variable; vertical scrollbar never appears because line count always fits the list height). - H/L keys and
ScrollLeft/ScrollRightboth updatestate.list_names_scroll_x. - Focus set on click via
update_scroll_focus; persists until user clicks the right pane. - Focus cleared by
clamp_list_scroll_for_areawhen content width ≤ viewport (content fits horizontally).
Tab Bar
Section titled “Tab Bar”W3C ARIA Tabs pattern (see AGENTS.md → TUI navigation conventions for full spec). Summary:
- Tab bar has its own focus area (
tab_bar_focused: boolin state). - Tab bar focused: active tab shows
fg(WHITE) + BOLD; underline bar (━━━━) beneath the active tab label. - Content focused (tab bar not focused): active tab shows
bg(PHOSPHOR_GREEN) + fg(Black) + BOLD; no underline bar. ←/→cycle tabs when tab bar is focused.Tab/↓moves focus from tab bar into the first content block.BackTab/Escreturns focus to the tab bar from content.
Modal Sizing Rules
Section titled “Modal Sizing Rules”Modals use centered_rect_fixed(outer, pct_w, rows). rows is the total outer height including both borders. Inner rows = rows - 2.
| Modal | pct_w | rows | Reasoning |
|---|---|---|---|
| Scope picker | 50 | 4 | 1 button row + 2 borders + 1 spacer |
| Text input | 60 | 5 | 1 input row + 2 borders + 2 label/padding |
| Role picker | auto | filtered.len() + 6 | filter row + spacer + roles + border overhead |
Never add excess padding rows to modals. The outer rect must be exactly tight enough to render the widget’s layout constraints without blank overflow rows.
Filter rows sit directly under the top border
Section titled “Filter rows sit directly under the top border”For every modal that has a Filter: input (the host role picker, the host op picker, the in-multiplexer Menu / SplitDirectionPicker / AgentPicker), the filter input row must render immediately below the top border — no leading blank pad row between the border and the filter. The interior layout, top-to-bottom, is:
| Row | Contents |
|---|---|
| 0 (interior row 0) | Filter: <input> |
| 1 (interior row 1) | blank spacer |
| 2+ | list rows |
Equivalently: the filter starts at box_row + 1, the spacer at box_row + 2, and the first list row at box_row + 3. The natural box height is items + 4 (top border + filter + spacer + items + bottom border). Both the host pickers (src/console/widgets/role_picker.rs and src/console/widgets/op_picker/render.rs) and the in-multiplexer pickers (crates/jackin-capsule/src/dialog.rs) honour this layout — an operator’s eye must read identically across surfaces.
Why this rule exists: a leading blank pad row pushes the filter one row down and visually disconnects it from the title in the top border. The eye reads it as “the filter is floating in the middle of the dialog,” not as “type here to narrow the list” — a small but cumulative perceptual cost across the many dialogs jackin opens during a typical session. Drop the pad; keep the filter glued to the chrome.
Confirmation dialogs use the canonical Yes/No layout
Section titled “Confirmation dialogs use the canonical Yes/No layout”Every Yes/No confirmation rendered on any jackin’ TUI surface — host console (src/console/widgets/confirm.rs) and the in-container multiplexer (crates/jackin-capsule/src/dialog.rs‘s Dialog::ConfirmAction) — must use the same compact shape so the operator’s eye recognises “this is a confirmation” instantly regardless of surface. Different shapes per surface train the operator to re-read each dialog from scratch and erode the muscle memory for N / Esc / Enter.
The canonical layout, top to bottom, inside the dialog’s interior columns:
- Box title is the literal string
Confirm— not the question, not a kind-specific noun. The question itself goes in the body. Operators scanning the screen seeConfirmand know without reading further what kind of widget this is. - Question (e.g.
Close pane?,Exit jackin'?,Delete "scentbird"?): one line, centered, white + bold. The question must end with a?so the body reads as a prompt, not as a statement. - Optional explanation (one line, dim, centered) below the question, when the destructive scope is non-obvious. Wrap explanations longer than one interior-width line into the first line; the second line of context, if any, lives in the docs the action is about, not the confirm.
- Two side-by-side buttons centred at the bottom:
YesandNoseparated by four spaces of gap. The focused button gets aWHITEbackground +BLACKforeground + bold; the unfocused button staysPHOSPHOR_GREEN+ bold over the dialog background. - Default focus =
No. Confirms exist because the action is destructive; Enter on a freshly-opened confirm must never fire the destructive arm. The operator has to deliberately Tab / Right / Y to commit Yes. - Key bindings (TUI design contract, not surface-specific): Tab / Right / Left / Shift-Tab cycle focus,
Y/yalways commits Yes,N/n/ Esc always cancels, Enter commits whichever button is focused. Mouse click on a button focuses + commits in one step (matches W3C button semantics).
The reusable host widget is <RepoFile path="src/console/widgets/confirm.rs" /> (ConfirmState + render). In-container surfaces that cannot link the host crate (different binary, separate process) must mirror the layout in their own renderer — the in-container daemon’s render_confirm_action in crates/jackin-capsule/src/dialog.rs is the canonical copy and any other in-container confirm must call it (or a sibling helper with the same layout output) rather than rolling a third shape.
Why this rule exists: confirms are the operator’s “are you sure?” gate. They appear at the worst moments — about to lose work, about to nuke a workspace, about to ship a destructive change. Surface-specific variants (“Yes, continue” / “No, go back” stacked vertically on one screen; ” Yes ” / ” No ” inline on the next) make the operator’s eyes hunt for the right button under stress. The cost of one shared layout is small; the cost of mis-clicking a destructive confirm because the buttons moved is huge.
Sub-dialogs push onto a stack — Esc walks back one step
Section titled “Sub-dialogs push onto a stack — Esc walks back one step”Applies to every TUI surface jackin renders — host console (src/console/) and the in-container multiplexer (crates/jackin-capsule/). Any modal that opens another modal as part of its flow MUST preserve the parent so a Esc press returns to it, not to the underlying screen. The operator’s mental model is a breadcrumb chain; repeated Esc presses walk that chain one step at a time until the operator reaches the original surface (workspace list, secrets tab, focused pane).
Concrete obligations:
- Open: push the new modal on top of the parent — do not overwrite the slot.
- Esc / Cancel: pop one frame. Operator returns to whichever modal was visible immediately before.
- Mouse-click outside the box: pops one frame, matching Esc.
- Commit / terminal action (agent picked, role selected, destructive action confirmed, copy fired, env key + value both committed): clear the entire chain so the operator lands on the underlying screen in one step. A terminal action means “the chain achieved its goal” — walking back through every intermediate step would be noise.
- Sub-dialog titles read as a step inside the parent’s flow —
Split: ← Leftfor the agent picker opened from aSplitDirectionPicker, notAgent pickeragain. The title is what surfaces the breadcrumb to the operator.
Three implementation shapes carry this contract today:
Vec<Dialog>stack (canonical, used bycrates/jackin-capsule/src/dialog.rs).dialog_top()is “is a dialog open”; entries land viadialog_push; back-nav arms calldialog_pop_one; terminal-action arms calldialog_clear.modal_parentsstacks on host console state (used by workspace editor modals, settings global-mount modals, and settings environment modals).open_sub_modalstashes the visible modal as a parent,pop_modal_chainrestores it on Esc, andclear_modal_chaincloses the whole flow after a terminal commit.- Per-flow return-path stash (legacy, used by host console for auth form side trips —
pending_auth_form_return,pending_env_key,pending_picker_target). The parent’s state is stashed in anEditorState/ settings-auth field before the child modal mounts, and the child’s cancel arm rebuilds the parent from the stash. Works for shallow chains but does not compose past two levels and is easy to forget when adding a new transition.
The bare Option<Modal> slot in src/console/manager/state.rs is not a valid shape by itself — it cannot represent a parent under a child, so any new modal→modal transition added to a code path that still uses only Option<Modal> regresses this rule by construction. New code MUST use one of the supported shapes above; the eventual direction for the host console is to converge on stack-backed modal state and retire the stash slots.
Compliance is covered by focused tests for the container palette stack, editor environment-source and mount-destination chains, settings environment chains, and settings global-mount add chains. Treat new Esc-back regressions on any surface as bugs; fix the immediate flow with stack push/pop unless a legacy auth stash already owns the return path.
Hints / Footer Bar
Section titled “Hints / Footer Bar”Every keyboard shortcut active on the current screen must appear in the footer. No hidden keys. See AGENTS.md → Navigation hints for the complete rule.
When a scrollable block is focused, the footer must show scroll direction hints:
- Horizontal-only block:
←/→ scroll block - Vertical-only block:
↑/↓ scroll block - Both axes:
↑/↓/←/→ scroll block
The hints update dynamically based on which block is focused and which axes overflow.
Env / Secrets Sentinel Indentation
Section titled “Env / Secrets Sentinel Indentation”+ Add environment variable sentinel at workspace level must align with workspace-level content (2-char cursor_col indent only). It must not pick up the 5-space marker_col that role-level rows use.
Role-level sentinels (RoleAddSentinel) and role section headers keep their " " (5-space) indentation to visually group them under the role section.
Settings ↔ Workspace Editor Parity
Section titled “Settings ↔ Workspace Editor Parity”The settings panel (ManagerStage::Settings, global config) and the workspace editor (ManagerStage::Editor, per-workspace config) are parallel tabbed surfaces. Their tabs mirror each other:
| Settings tab | Workspace editor analog |
|---|---|
| Mounts | Mounts |
| Environments | Secrets |
| Auth | Auth |
| Trust | (no direct analog) |
They share the same visual language, the same tab-bar interaction pattern, and the same scrollable-block conventions. Operators move between them constantly and expect consistent behavior.
Rule: every fix or addition that lands in one panel must immediately be audited against the other panel. If the same issue exists there, fix it in the same PR. This is not optional polish — inconsistent behavior between the two panels is a bug.
What to check on every change
Section titled “What to check on every change”When modifying settings tabs, verify the workspace editor tabs (and vice versa):
- Scroll focus wiring: every tab must have a
scroll_focusedfield (or use a shared field liketab_content_scroll_focused) that is set by the mouse click handler and passed asfocusedtorender_scrollable_block. No tab may hardcodefocused = false. - Vertical scroll state: every tab that can overflow vertically must have a
scroll_ystate field thatrender_scrollable_blockmutates in-place. Never use a throwaway localno_scroll_y = 0u16unless the tab is genuinely designed to be non-scrollable vertically (and even then, verify the counterpart tab makes the same choice intentionally). - Content width agreement: the content width used by the mouse wheel and drag handlers must be computed from the same line-building function the renderer uses. Mismatched width computations make the scrollbar thumb stop short of the track end.
- Mouse wheel vertical dispatch:
scroll_active_panel_verticalmust route to the correctscroll_yfield for every tab in both panels. A missing branch silently drops scroll events. - Focus highlight: the green border must appear when and only when the block is focused and scrollable, in both panels identically.
Why this rule exists
Section titled “Why this rule exists”Operators use both surfaces regularly. When Settings Auth scrolls but workspace editor Auth does not (or vice versa), the UI feels broken even if each panel works correctly in isolation. The two panels look alike; they must act alike. Divergence accumulates invisibly until a new operator encounters it and files a bug.
The shared renderer (render_scrollable_block, render_scrollable_block_focused) and shared scroll helpers (scrollable.rs) enforce visual consistency automatically. The state wiring and mouse handlers are the remaining surface where inconsistency can creep in — which is why this explicit audit rule exists.
Error Surface — ErrorPopup Only, No Ephemeral Overlays
Section titled “Error Surface — ErrorPopup Only, No Ephemeral Overlays”All error conditions surfaced to the operator must use the red-border ErrorPopup dialog (src/console/widgets/error_popup.rs). Ephemeral overlays (shimmer banners, auto-expiring toasts, single-line status strips) are banned for error display.
Why: Auto-expiring errors vanish before the operator reads them, have no scroll support for long messages, and render over content rather than blocking it. ErrorPopup is dismissible (Enter / Esc / O), scrollable, and modal — the operator cannot miss it or accidentally dismiss it.
Scope: Every error path in every stage (list, editor, settings, create-prelude) must route through the stage’s designated error slot:
- List stage:
ManagerState.list_modal = Some(Modal::ErrorPopup { .. }) - Editor stage:
EditorState.modal = Some(Modal::ErrorPopup { .. }) - Settings stage:
SettingsState.error_popup = Some(ErrorPopupState::new(title, body)); theafter_settings_eventhelper promotes errors from sub-tab.errorfields into this slot.
Background errors (e.g., refresh_instances index parse failures) use the list-stage list_modal slot with a dedup gate (instances_last_error) to prevent the popup from reopening on every 20 Hz refresh tick.
Success feedback is silent. Navigation back to the workspace list after a successful save is sufficient confirmation. No success toast, no success popup.
Component Reuse — Hard Rule
Section titled “Component Reuse — Hard Rule”Never copy-paste a TUI component. Extend or compose instead. This is the single most important architectural rule for the jackin’ TUI.
Core rules
Section titled “Core rules”-
Every visual pattern that appears in more than one place must use one shared implementation. A single widget, render function, or helper — not two near-copies. When the existing implementation cannot serve a new call site without modification, extend it (add a parameter, generalize a branch, add an enum variant) rather than forking it.
-
Components live in
src/console/widgets/. Before writing any new TUI state machine or renderer,grepthe directory. If a widget handles the same visual pattern (drill-down picker, text input box, 2-button choice, yes/no confirm, filter list), extend that widget rather than creating a parallel one. -
A genuinely new component is only ever written once. The first time a new UX pattern appears, it is acceptable to implement it in the most focused way that serves the immediate need. The second time the same pattern is needed somewhere else, the two implementations must be consolidated into one shared widget before the second PR lands — not “as a follow-up.”
-
Copy-paste for convenience is a blocker. When a PR produces a second near-copy of an existing widget or render function — even 90% similar, even with minor additions — that is a TUI design-decision violation. Reviewers block merge; the fix is consolidation, not a comment.
-
Refactoring to enable reuse is not optional. If an existing component does not yet accept a parameter that a new call site needs, add the parameter. If an existing component mixes two responsibilities that need to be separated, separate them. The cost of a targeted refactor is always lower than the cost of maintaining two divergent implementations through every future bug fix and enhancement.
-
Settings screens mirror workspace screens. Settings surfaces that intentionally parallel workspace editor screens must reuse the workspace widgets and flow helpers wherever behavior is the same; keep separate code only for the different persistence target or config scope. Visual drift between the two is a bug.
What to check before writing new code
Section titled “What to check before writing new code”Before adding any new TUI widget or state type:
- Run
grep -r 'struct.*State' src/console/widgets/andls src/console/widgets/. Read the matches. Is there a widget that already owns the pattern? - Run
grep -r 'fn render_' src/console/widgets/for the render-level equivalent. - If yes — extend the existing widget. Add a mode enum, a new bool flag, or a new variant. Do not create a second module.
- If no — propose the design first; get explicit approval; then implement. The first implementation goes into
src/console/widgets/so the next caller can find it.
Why this rule exists — canonical lesson
Section titled “Why this rule exists — canonical lesson”A token_store_picker widget once duplicated the Account → Vault → Item navigation state machine from src/console/widgets/op_picker/mod.rs — a 1067-line copy of closely parallel code where every op_picker bug fix to navigation, filtering, R-key refresh, or section display had to be manually reproduced or the two implementations drifted. That copy has since been deleted and op_picker was extended instead: an opt-in OpPickerMode::Create turns on the + New item, + New field, and + New section creation sentinels and the naming sub-stages, and the picker commits an OpPickerSelection enum (Existing / NewItem / EditItemField). One widget now serves both the browse and create call sites. This is the positive reference for the rule: when a new call site needs more, extend the shared widget with a mode, never fork it into a second drill-down. The remaining Claude-token work is tracked in the Workspace Claude Token Setup roadmap item.
Reusable Component Catalog
Section titled “Reusable Component Catalog”All shared TUI widgets live in src/console/widgets/. Use this table to identify the right component before writing new code.
| Component | Module | What it provides | When to use |
|---|---|---|---|
| Op picker (1Password drill-down) | src/console/widgets/op_picker/mod.rs | Account → Vault → Item → Field 4-stage drill-down with filter, R-key refresh, collapsible section headers, background loading, and op:// reference commit. An opt-in OpPickerMode::Create adds + New item / + New field / + New section creation sentinels and naming sub-stages, committing an OpPickerSelection enum | Any flow that needs to browse, pick, or create a 1Password field reference |
| Role picker | src/console/widgets/role_picker.rs | Filter list of available roles with type-to-narrow and Enter commit | Any flow that needs to select a role from the known set |
| Text input box | src/console/widgets/text_input.rs | Single-line labelled input box (TextInputState + render) with validation, duplicate detection, forbidden-char rules. This is the “Credential” dialog the operator sees when typing any single value. | Any flow needing one line of free text — env value, item name, field label, section name, workspace name |
| Scope picker | src/console/widgets/scope_picker.rs | Two-button horizontal strip: All roles / Specific role | Any “apply to everything vs apply to one” choice |
| Source picker | src/console/widgets/source_picker.rs | Two-button horizontal strip: Plain text / 1Password | Choosing whether a value is literal or an op:// reference |
| Agent choice | src/console/widgets/agent_choice/mod.rs | Two-button agent selector used in the Auth-tab + Add flow | Picking one agent from a small fixed set |
| Mount destination choice | src/console/widgets/mount_dst_choice.rs | Two-button strip for mount destination choices | Mount configuration flows |
| Confirm dialog | src/console/widgets/confirm.rs | Yes/No two-button confirm with the canonical layout (see Confirmation dialogs rule) | Any destructive or irreversible action |
| Save / discard strip | src/console/widgets/save_discard.rs | Save / Discard two-button strip, designed for form footers | Bottom of any editable form |
| File browser | src/console/widgets/file_browser/mod.rs | Host filesystem navigation | Any flow needing to pick a local file or directory |
| GitHub picker | src/console/widgets/github_picker.rs | GitHub org/repo drill-down | Any flow needing to select a GitHub repository |
| Error popup | src/console/widgets/error_popup.rs | Scrollable red-border error modal (see Error Surface rule) | All error display |
| Panel rain | src/console/widgets/panel_rain.rs | Background rain animation | Background decoration |
Render primitives (in src/console/widgets/op_picker/render.rs and src/console/manager/render/mod.rs): modal_block, breadcrumb_title, render_filter_row, render_fatal, render_scrollable_block — use these building blocks when composing new modals rather than hand-rolling border + layout from scratch.
Two-button choice pattern
Section titled “Two-button choice pattern”scope_picker.rs, source_picker.rs, agent_choice/, mount_dst_choice.rs, and confirm.rs are all the same visual shape: a small modal with two side-by-side buttons, ←/→ to move, Enter to select, Esc to cancel. When a step requires the operator to pick one of exactly two mutually-exclusive options, reuse one of these or model a new one on the identical shape — never invent a third layout. The operator must recognise “this is a two-way choice” instantly from the shape alone.
One input-box dialog for every single-value prompt
Section titled “One input-box dialog for every single-value prompt”Every “ask the operator to type one value” prompt on the host console MUST use the shared text_input dialog (src/console/widgets/text_input.rs — TextInputState + render). That render fn draws the whole dialog: a single bordered box, the label as the title, the input row, the dim input band. It is the box behind “New global environment key”, the credential entry, the workspace-name prompt, and the op_picker Create-mode naming stages. Do not hand-roll a second input box, and do not wrap text_input::render inside another bordered block (that produces a box-inside-a-box). The dialog is a single box sized by text_input_rect, with the footer showing Enter confirm · Esc cancel — supplied by the modal footer, never drawn inside the box (see “Hints: footer only”). When a flow that already owns a larger modal (like the op_picker drill-down) needs a one-value sub-prompt, render the plain text_input box at the text-input rect for that sub-stage rather than nesting it in the parent frame. The in-container multiplexer (crates/jackin-capsule/) is a separate process that cannot link the host crate; it mirrors the same single-box shape in its own dialog.rs rather than sharing this code.
Default values are pre-filled in input boxes
Section titled “Default values are pre-filled in input boxes”When an input box has a suggested default, that default MUST be pre-filled into the box (cursor at end), not shown as placeholder/ghost text. The operator either presses Enter to accept the pre-filled value or edits it. Never make the operator type a value that the system already knows the default for. This matches the CLI prompt convention (dialoguer::Input::default(...), where Enter accepts the suggestion) so the two surfaces behave identically. Construct the input with the default as its initial content — TextInputState::new(label, default) already does this (the textarea opens holding default with the cursor at the end). Use an empty initial value ("") only when there is genuinely no sensible default (e.g. a new section name). Example: the op_picker Create-mode naming stages open with Claude (item) and oauth-token (field) pre-filled; the operator hits Enter to take them or types over them.
Creation-sentinel pattern (+ Add X / + New X)
Section titled “Creation-sentinel pattern (+ Add X / + New X)”jackin’ has one convention for “add a new thing to this list”: a sentinel row rendered at the bottom of the list with a leading + and the action label. It is the same across every surface:
+ New workspace— list stage (src/console/manager/render/list.rs)+ Add role— workspace editor Roles tab (src/console/manager/render/editor.rs)+ Add mount,+ Add environment variable,+ Add {role} environment variable— settings/editor mount + env tabs (src/console/manager/render/global_mounts.rs)+ Override for a role— Auth tab per-role override
Selecting a creation sentinel opens the next step in the flow — usually the text input box to name the new thing, or a two-button choice. Any new “add an item to this list” affordance MUST render as a + sentinel row in the same position and style, and MUST route its selection through the text input box (for naming) or a picker (for choosing). Do not invent a different add-affordance (no separate “New…” button floating elsewhere, no key-only shortcut without a visible row).
Wizard-Style Dialog Flows
Section titled “Wizard-Style Dialog Flows”When a feature requires the operator to complete several sequential decisions before committing (pick a scope, pick a role, pick a source, select a credential location), implement it as a wizard flow: a chain of modals where each step is handled by a reusable component and Esc walks back one step.
- Each step is one reusable component from the catalog above. Do not design a custom combined-step UI.
- Modal stack discipline applies (see Sub-dialogs push onto a stack). Each step pushes onto the modal stack;
Escpops one step; a terminal commit clears the whole chain. - The first step in the chain is opened from whatever UI element the operator interacts with (a button, a hint, an auth-tab action). It does not require a CLI flag.
- The flow lives in the TUI, not in the CLI. The CLI is for scripted or non-interactive invocations. The TUI is for interactive flows.
Canonical example — Claude token setup flow
Section titled “Canonical example — Claude token setup flow”This is the reference example for how to design any multi-step TUI flow in jackin’, and it is shipped. Every screen reuses a component from the catalog above; the flow adds zero new widgets and only extends op_picker (OpPickerMode::Create). Read this whenever you design a new flow and ask “which existing component is each of my screens?” before writing any code.
The flow configures a Claude OAuth token, launched entirely from inside jackin console — the operator never leaves the console and never runs a CLI command.
Scope comes from the auth form, not a picker step. The operator chooses what the token wires by opening the right Claude oauth_token auth form: the workspace-level form (all roles), a per-role override form (reached via + Override for a role), or the global form in Settings. There is deliberately no scope/role picker inside the generate flow — the auth tab’s existing structure is the scope chooser, so the flow needs no scope_picker/role_picker step.
Entry point — G on the Edit auth dialog. With the form on Mode: oauth_token, G (shown in the footer as G generate) starts the flow. The screens, each mapped to the component it reuses:
| Step | Screen | Component reused | Notes |
|---|---|---|---|
| 1 | Source: Plain text / 1Password | Source picker (source_picker.rs) | The same two-button dialog the provide flow uses. |
| 2a (if “Plain text”) | (no screen) | — | The token is minted by claude setup-token and stored as a literal at the scope; nothing is typed. |
| 2b (if “1Password”) | Account → Vault → Item → [Section →] Field drill-down | op_picker (Create mode) | The existing op_picker, reused for the drill-down; OpPickerMode::Create adds the Section stage + creation sentinels below. |
After the source/location choice, the console suspends, runs the claude setup-token OAuth capture, writes the minted token, and resumes — the storage location was already chosen, so the mint is the only blocking step.
Step 2b in detail — extending op_picker, not copying it. The 1Password branch reuses the existing op_picker Account, Vault, and Item screens exactly as they render today. Create mode adds a Section stage for an existing item and creation sentinels, each following the + Add X pattern (a + row at the bottom of the list):
- Item stage — a
+ New itemsentinel below the item list. Selecting it opens the text input box to name the new item, then names the field — the token is written there. - Section stage (existing item only) — rows are
(root)(unsectioned fields), each existing named section, and a+ New sectionsentinel. Picking a section scopes the next screen;+ New sectionopens the text input box to name it. - Field stage (scoped to the chosen section) — the existing fields in that section, plus a
+ New fieldsentinel that opens the text input box to name the field. - Selecting an existing field writes the token into that field in-place (overwrite); creation rows write into the chosen section (
(root)⇒ unsectioned). Browse mode skips the Section stage and keeps the flat field list with collapsible section headers.
Every “create” affordance in this branch is the same + sentinel row, and every “name it” prompt is the same text input box. Nothing new is drawn.
Implementation contract. These sentinels are shipped in op_picker behind the opt-in OpPickerMode::Create mode parameter that turns the creation rows on; the picker commits an OpPickerSelection enum (Existing / NewItem / EditItemField) instead of only the plain op:// reference string. This capability now lives in the shared src/console/widgets/op_picker/mod.rs — it MUST NOT be reimplemented by forking op_picker into a second module.
Rich TUI lives in the console; the CLI stays plain CLI
Section titled “Rich TUI lives in the console; the CLI stays plain CLI”There are two distinct interactive surfaces, and the established principle keeps them apart: a rich ratatui TUI belongs only inside jackin console; the CLI never spins one up.
- CLI
--interactive.jackin workspace claude-token setup <ws> --interactivewalks account → vault → item → field with plain CLI prompts (dialoguer Select/Input), offering[ + New item ]and[ + New field ]at the relevant steps. It does not open an alt-screen TUI. This keeps the CLI composable with a plain shell, scripts, and pipes, and means an operator at a terminal never has a second raw-mode UI fight their own. - Console wizard flow. The rich, multi-step flow above belongs in
jackin console, opened from the Auth tab — already on screen when the operator wants to configure a token — using the console’s own modal stack so the operator never leaves the console and keeps all of its context (workspace state, auth-tab focus, in-progress edits). Every screen reuses a shared component:source_picker,op_pickerin Create mode, and thetext_inputbox for the naming sub-stages. (Scope is the auth form itself, so noscope_picker/role_pickerstep appears in the generate flow.)
The shared op_picker Create mode, the plain-prompt CLI path, and the console-side generate-token action are all shipped. On a Claude oauth_token auth form, G opens a source step (Plain text / 1Password); for 1Password it opens op_picker in Create mode. Pressing G stashes the open auth form into the same return-path slot the provide flow uses, so the dialog can be re-mounted when the mint finishes. On commit the run_console loop suspends the terminal (leaves raw-mode + alt-screen, mirroring TerminalGuard), runs the claude setup-token OAuth mint + the 1Password item create / read-back validation via token_setup::mint_token_value (which mints and validates but does not write jackin config), then resumes — the one place in the console that hands the real terminal to a child process. On success the loop re-mounts the Edit-auth dialog with the minted credential staged and focus on Save (via the same apply_op_picker_to_auth_form / apply_plain_text_to_auth_form helpers the provide path uses); the credential is persisted only when the operator Saves, exactly like picking an existing value. Cancel leaves the config unwired (any op item already created is harmless, matching the provide path). The scope follows the auth-form target: workspace level, a per-role override (open that role’s form), or global (Settings). The plain-text target stores the minted token as a literal at that scope; 1Password stores an op:// ref. The CLI path (token_setup::run_setup) keeps minting and persisting in one step because it has no form to return to. See Workspace Claude Token Setup.
Why reusability matters — the design principle behind this flow
Section titled “Why reusability matters — the design principle behind this flow”The reason every screen above reuses an existing component is not code economy — it is operator trust. When the same meaning is always shown with the same shape, the operator learns the UI once and recognises it everywhere: a two-button dialog always means “pick one of two”, a + row always means “add a new one”, the bordered filter list always means “drill down”, the input box always means “type a value here”. A flow assembled from these known shapes is legible on first sight even though the operator has never seen this particular flow before. A flow that invents new shapes for the same meanings forces the operator to re-learn the UI at every screen and erodes that trust. Consistency in design is not polish; it is the contract that lets the operator predict what each screen does. This is why copy-pasting a component with slightly different look-and-feel is a bug, not a shortcut: it breaks the one-meaning-one-shape contract that makes the whole TUI predictable.