Skip to content

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.


jackin’ TUI follows the W3C ARIA Tabs interaction pattern for all tabbed interfaces and the corresponding area-scoped arrow-key model everywhere else.

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:

  1. Tab list (the row of tab labels) is its own focus area:

    • / cycle between tabs.
    • Tab or 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.
  2. Tab panel (the content below the tabs) may contain one or more blocks:

    • / navigate within the focused block.
    • Tab advances to the next block within the panel; after the last block Tab cycles back to the tab list.
    • BackTab / Esc returns focus to the tab list.
  3. 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 before Tab / 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.

  • 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.

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:

  • ↑/↓ navigate must 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 GitHub only 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, and Esc back/discard are always shown in the global footer.
  • Keep each hint concise: one symbol or key name, one word label. Use · separators between hints.

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): Space only.
  • Action button (Save, Cancel, Discard, Yes, No): Enter only (plus letter shortcut if applicable).
  • Open/navigate (expand detail, open picker, rename): Enter only.
  • 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). P is also supported as a shortcut where op_available is true, but Enter must always work.

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 a footer_items function (or equivalent) and remove its internal hint row.
  • render_editor checks state.modal first; if a modal is open it calls modal_footer_items(modal) and skips the normal contextual items.
  • render_settings checks the three settings modal chains (auth.modal, env.modal, mounts.modal) in priority order; if any is Some, calls the corresponding *_modal_footer_items function.
  • No Constraint::Length row for a hint line may remain inside any widget layout.

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.


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.

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.

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:

  1. Mouse click handler (update_scroll_focus) — set the focused / scroll_focused state 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 add is_scrollable guards 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.

  2. Resize guard — when the terminal shrinks and content that previously overflowed now fits, list_scroll_focus (list view) is cleared by focused_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:

Blockcontent_height
Workspace mounts1 + max(data_rows, 1) where data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise)
Global mounts / Role global mounts1 + data_rows where data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise); empty sections render one placeholder row
Roles2 + 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.


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_GREEN when focused, PHOSPHOR_DARK otherwise.
  • Horizontal scrollbar rendered only when content overflows horizontally.
  • Vertical scrollbar rendered only when content overflows vertically.
  • *scroll_x and *scroll_y clamped 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.


list_names_focused (left sidebar) is cleared only when:

  1. The user clicks in the right pane — update_scroll_focus sets it to false.
  2. The terminal resizes so the content fits — clamp_list_scroll_for_area sets it to false.

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:

  1. Content no longer overflows after resize — focused_block_still_scrollable returns false.
  2. User clicks left pane — update_scroll_focus clears it.
  3. Workspace changes — reset_list_scroll sets it to None.

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:

  1. If list_names_focused → scroll list_names_scroll_x (left pane).
  2. 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.


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.

ConstantValueUsage
PHOSPHOR_GREENRgb(0, 255, 65)Active/focused elements, selected text
PHOSPHOR_DIMRgb(0, 140, 30)Inactive text, scrollbar thumbs
PHOSPHOR_DARKRgb(0, 80, 18)Borders (unfocused), separators
WHITERgb(255, 255, 255)Labels, keys, headings

  • Focused and scrollable: PHOSPHOR_GREEN border — both focused = true AND content overflows in at least one axis.
  • Focused but not scrollable: PHOSPHOR_DARK border — focused = true is ignored when content fits; no false affordance.
  • Unfocused: PHOSPHOR_DARK border 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.


  • Horizontal scroll only — no vertical scroll.
  • Uses render_scrollable_block with scroll_y = 0u16 (a throwaway variable; vertical scrollbar never appears because line count always fits the list height).
  • H/L keys and ScrollLeft/ScrollRight both update state.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_area when content width ≤ viewport (content fits horizontally).

W3C ARIA Tabs pattern (see AGENTS.md → TUI navigation conventions for full spec). Summary:

  • Tab bar has its own focus area (tab_bar_focused: bool in 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/Esc returns focus to the tab bar from content.

Modals use centered_rect_fixed(outer, pct_w, rows). rows is the total outer height including both borders. Inner rows = rows - 2.

Modalpct_wrowsReasoning
Scope picker5041 button row + 2 borders + 1 spacer
Text input6051 input row + 2 borders + 2 label/padding
Role pickerautofiltered.len() + 6filter 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:

RowContents
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:

  1. 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 see Confirm and know without reading further what kind of widget this is.
  2. 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.
  3. 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.
  4. Two side-by-side buttons centred at the bottom: Yes and No separated by four spaces of gap. The focused button gets a WHITE background + BLACK foreground + bold; the unfocused button stays PHOSPHOR_GREEN + bold over the dialog background.
  5. 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.
  6. Key bindings (TUI design contract, not surface-specific): Tab / Right / Left / Shift-Tab cycle focus, Y / y always 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: ← Left for the agent picker opened from a SplitDirectionPicker, not Agent picker again. The title is what surfaces the breadcrumb to the operator.

Three implementation shapes carry this contract today:

  1. Vec<Dialog> stack (canonical, used by crates/jackin-capsule/src/dialog.rs). dialog_top() is “is a dialog open”; entries land via dialog_push; back-nav arms call dialog_pop_one; terminal-action arms call dialog_clear.
  2. modal_parents stacks on host console state (used by workspace editor modals, settings global-mount modals, and settings environment modals). open_sub_modal stashes the visible modal as a parent, pop_modal_chain restores it on Esc, and clear_modal_chain closes the whole flow after a terminal commit.
  3. 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 an EditorState / 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.


Every keyboard shortcut active on the current screen must appear in the footer. No hidden keys. See AGENTS.mdNavigation 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.


+ 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.


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 tabWorkspace editor analog
MountsMounts
EnvironmentsSecrets
AuthAuth
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.

When modifying settings tabs, verify the workspace editor tabs (and vice versa):

  • Scroll focus wiring: every tab must have a scroll_focused field (or use a shared field like tab_content_scroll_focused) that is set by the mouse click handler and passed as focused to render_scrollable_block. No tab may hardcode focused = false.
  • Vertical scroll state: every tab that can overflow vertically must have a scroll_y state field that render_scrollable_block mutates in-place. Never use a throwaway local no_scroll_y = 0u16 unless 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_vertical must route to the correct scroll_y field 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.

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)); the after_settings_event helper promotes errors from sub-tab .error fields 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.

Never copy-paste a TUI component. Extend or compose instead. This is the single most important architectural rule for the jackin’ TUI.

  1. 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.

  2. Components live in src/console/widgets/. Before writing any new TUI state machine or renderer, grep the 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.

  3. 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.”

  4. 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.

  5. 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.

  6. 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.

Before adding any new TUI widget or state type:

  1. Run grep -r 'struct.*State' src/console/widgets/ and ls src/console/widgets/. Read the matches. Is there a widget that already owns the pattern?
  2. Run grep -r 'fn render_' src/console/widgets/ for the render-level equivalent.
  3. If yes — extend the existing widget. Add a mode enum, a new bool flag, or a new variant. Do not create a second module.
  4. 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.

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.


All shared TUI widgets live in src/console/widgets/. Use this table to identify the right component before writing new code.

ComponentModuleWhat it providesWhen to use
Op picker (1Password drill-down)src/console/widgets/op_picker/mod.rsAccount → 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 enumAny flow that needs to browse, pick, or create a 1Password field reference
Role pickersrc/console/widgets/role_picker.rsFilter list of available roles with type-to-narrow and Enter commitAny flow that needs to select a role from the known set
Text input boxsrc/console/widgets/text_input.rsSingle-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 pickersrc/console/widgets/scope_picker.rsTwo-button horizontal strip: All roles / Specific roleAny “apply to everything vs apply to one” choice
Source pickersrc/console/widgets/source_picker.rsTwo-button horizontal strip: Plain text / 1PasswordChoosing whether a value is literal or an op:// reference
Agent choicesrc/console/widgets/agent_choice/mod.rsTwo-button agent selector used in the Auth-tab + Add flowPicking one agent from a small fixed set
Mount destination choicesrc/console/widgets/mount_dst_choice.rsTwo-button strip for mount destination choicesMount configuration flows
Confirm dialogsrc/console/widgets/confirm.rsYes/No two-button confirm with the canonical layout (see Confirmation dialogs rule)Any destructive or irreversible action
Save / discard stripsrc/console/widgets/save_discard.rsSave / Discard two-button strip, designed for form footersBottom of any editable form
File browsersrc/console/widgets/file_browser/mod.rsHost filesystem navigationAny flow needing to pick a local file or directory
GitHub pickersrc/console/widgets/github_picker.rsGitHub org/repo drill-downAny flow needing to select a GitHub repository
Error popupsrc/console/widgets/error_popup.rsScrollable red-border error modal (see Error Surface rule)All error display
Panel rainsrc/console/widgets/panel_rain.rsBackground rain animationBackground 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.

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.rsTextInputState + 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:

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


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; Esc pops 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:

StepScreenComponent reusedNotes
1Source: Plain text / 1PasswordSource 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-downop_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 item sentinel 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 section sentinel. Picking a section scopes the next screen; + New section opens the text input box to name it.
  • Field stage (scoped to the chosen section) — the existing fields in that section, plus a + New field sentinel 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> --interactive walks 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_picker in Create mode, and the text_input box for the naming sub-stages. (Scope is the auth form itself, so no scope_picker/role_picker step 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.