jackin'
Behind jackin' — InternalsTUI Design

Component Reuse & Catalog

The hard reuse rule, the full reusable component catalog, and shared interaction patterns for building jackin TUI features.

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

  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 have two canonical homes. Cross-surface components live in crates/jackin-tui/src/components/. Surface-local components live in crates/<surface-crate>/src/tui/components/. The remaining root-console op-picker adapter in src/console/tui/op_picker/ is transitional until migrated; use it when extending current op-picker behavior, but do not add new canonical component patterns there.

  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.

  7. Component APIs follow the TUI boundary. Component props/state/messages may describe UI state and emit semantic outcomes. They must not own Docker/git/op/gh calls, config persistence, workspace resolution, or protocol authority. When a component needs work done, it emits an outcome/message that update turns into a typed effect.

What to check before writing new code

Before adding any new TUI widget or state type:

  1. Search the shared component crate first: rg 'struct .*State|fn render_|impl Widget|impl StatefulWidget' crates/jackin-tui/src/components crates/jackin-tui/src.
  2. Search the owning surface's final local component area: rg 'struct .*State|fn render_|impl Widget|impl StatefulWidget' crates/<surface-crate>/src/tui.
  3. While the console migration is incomplete, also search root-console TUI adapters before adding or moving anything: rg 'struct .*State|fn render_' src/console/tui.
  4. If a component already owns the pattern, extend it. Add a mode enum, parameter, flag, or semantic variant. Do not create a second module.
  5. If no component owns the pattern and only one surface needs it, add it under crates/<surface-crate>/src/tui/components/ with a thin components.rs export. If more than one surface needs it, add it under crates/jackin-tui/src/components/ with a lookbook story and visual regression coverage.
  6. When moving an existing component into the final structure, preserve behavior first: identify existing tests/snapshots or add focused coverage, move the code, keep compatibility shims only where needed for the migration, and verify the same keyboard, mouse, focus, footer, error, and render behavior afterward.

Why this rule exists — canonical lesson

A token_store_picker widget once duplicated the Account → Vault → Item navigation state machine from crates/jackin-console/src/tui/op_picker.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

Reusable TUI widgets live first in crates/jackin-tui/src/components/. Surface-local widgets should live under the owning surface's src/tui/components/. The root console still has the transitional op-picker adapter in src/console/tui/op_picker/; it remains the valid current owner until migrated, but that path is not the target structure for new canonical component work. Use this table to identify the right component before writing new code or moving an existing widget.

ComponentModuleWhat it providesWhen to use
Op picker (1Password drill-down)crates/jackin-console/src/tui/op_picker.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 pickercrates/jackin-console/src/tui/components/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 boxcrates/jackin-tui/src/components/text_input.rsSingle-line labelled input box (TextInputState + render_text_input), shared prompt geometry (text_input_prompt_rect), and the titled/labelled variant (render_labeled_text_input_dialog) 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 pickercrates/jackin-console/src/tui/components/scope_picker.rsTwo-button horizontal strip: All roles / Specific roleAny "apply to everything vs apply to one" choice
Source pickercrates/jackin-console/src/tui/components/source_picker.rsTwo-button horizontal strip: Plain text / 1PasswordChoosing whether a value is literal or an op:// reference
Agent choicecrates/jackin-console/src/tui/components/agent_choice.rsTwo-button agent selector used in the Auth-tab + Add flowPicking one agent from a small fixed set
Mount destination choicecrates/jackin-console/src/tui/components/mount_dst_choice.rsTwo-button strip for mount destination choicesMount configuration flows
Workdir pickercrates/jackin-console/src/tui/components/workdir_pick.rsFiltered list of mounted workspace pathsMount configuration flows that reuse an existing workspace path
Confirm dialogcrates/jackin-tui/src/components/confirm_dialog.rsYes/No two-button confirm with the canonical layout (see Confirmation dialogs rule), plus generic structured detail rows and note rows for prompts that need extra context. Product-specific strings are assembled by the owning surface, not by jackin-tui.Any destructive or irreversible action
Save / discard stripcrates/jackin-tui/src/components/save_discard_dialog.rsSave / Discard two-button strip, designed for form footersBottom of any editable form
Toastcrates/jackin-tui/src/components/toast.rsNon-blocking, auto-expiring overlay for state feedback such as Selection copied, with caller-supplied reserved rows so it never replaces footer/action hintsCopy-success or similar transient feedback that must appear outside the hint/footer row
File browsercrates/jackin-console/src/tui/components/file_browser.rs + crates/jackin-console/src/services/file_browser.rsHost filesystem navigation UI; navigation, commit validation, and Git URL lookup leave as typed outcomes for non-TUI adapters; browser-open queues ManagerEffect::OpenUrl for the root effect executorAny flow needing to pick a local file or directory
GitHub pickercrates/jackin-console/src/tui/components/github_picker.rsGitHub org/repo drill-downAny flow needing to select a GitHub repository
Error popupcrates/jackin-tui/src/components/error_dialog.rsScrollable red-border error modal (see Error Surface rule)All error display
Panel rainBackground rain animationBackground decoration

Console role-load error text is assembled through crates/jackin-console/src/tui/components/error_popup.rs. Root state may classify root-only repository and validation error types, but popup wording belongs to the console TUI component boundary.

Console effect executors use the same error-popup helper boundary for side-effect failure titles such as token generation, URL opening, workspace deletion, and file-browser startup. The executor owns the work and the concrete error value; crates/jackin-console/src/tui/components/error_popup.rs owns the visible popup title/state construction.

Background-work status popups follow the same split: crates/jackin/src/console/effects.rs starts role registration, workspace drift checks, and isolated-state cleanup, while crates/jackin-console/src/tui/components/status_popup.rs owns the visible status title/message construction.

Root-console tests should seed modals through the same crate-owned helpers as runtime whenever the helper exists. This keeps the final-structure audit honest: direct constructors in root tests are treated as drift unless the test is explicitly exercising the shared primitive itself.

Test-only helpers that need command runners, service calls, or config persistence belong with effect/service adapters, not input handlers. Role-input tests may use fake runners through crates/jackin/src/console/effects.rs, but crates/jackin-console/src/tui/input/editor.rs must stay limited to input-to-outcome routing and modal state transitions.

The transitional root op-picker adapter may own operator_env types, runner wiring, cache binding, subscriptions, and crossterm-to-outcome mapping, but user-visible picker copy, load-error state shaping, and blocked-load key policy belong in crates/jackin-console/src/tui/components/op_picker.rs with the rest of the component. Root adapters pass concrete process errors, subscription-closed facts, or Esc/not-Esc key facts into component helpers; they do not construct recoverable/fatal picker error states inline or duplicate the loading/fatal key-consumption rule.

Editor save prompts and save-blocking error text that depend only on visible editor facts live with editor screen view helpers in crates/jackin-console/src/tui/screens/editor/view.rs. Root save input and the effect executor still own drift detection, preserved-state cleanup, and workspace writes; they pass render-safe facts such as affected container names into the screen helpers.

Editor General-tab row vocabulary and content-width planning also live with editor screen view helpers in crates/jackin-console/src/tui/screens/editor/view.rs. Root layout code may shorten paths and pass current booleans, but it must not duplicate the row labels or enabled/disabled wording used by rendering.

Editor header title wording lives with editor screen view helpers in crates/jackin-console/src/tui/screens/editor/view.rs. Root rendering supplies only whether the editor is creating or editing and, for edit mode, the workspace name.

Settings header title wording lives with settings screen view helpers in crates/jackin-console/src/tui/screens/settings/view.rs. Root rendering should ask that helper for the screen title rather than carrying settings-screen copy.

Workspace-list header title wording lives with top-level console view helpers in crates/jackin-console/src/tui/view.rs. Root frame rendering should ask that helper for the workspace screen title rather than carrying workspace-screen copy.

Editor/settings save-footer base labels and pick-list modal commit labels live with console-local footer hint components in crates/jackin-console/src/tui/components/footer_hints.rs. Root footer adapters may pass dirty/change facts, row modes, and modal variants, but they should not carry the save workspace, save settings, select, or confirm copy.

Generated-token source-picker label construction lives with the auth-panel component in crates/jackin-console/src/tui/components/auth_panel.rs. Editor and settings input code decides when token generation starts, but visible source-picker state comes from the component helper.

Generated-token 1Password item-name default construction also lives with the auth-panel component in crates/jackin-console/src/tui/components/auth_panel.rs. Editor and settings token-generation input code should pass the template and scope label into the helper rather than hard-coding {ws} replacement in root input handlers.

Lookbook focus behavior

jackin-tui-lookbook (cargo run -p jackin-tui-lookbook -- --terminal) follows the same focus model as every other jackin' surface:

  • Sidebar / story list — focus starts here on launch; the sidebar panel shows a PHOSPHOR_GREEN border; / navigate stories; Tab or a click on the preview panel transfers focus to the preview.
  • Preview panel — shows a PHOSPHOR_GREEN border when focused; Esc/Tab/ returns focus to the sidebar; keyboard events route to the active component interactor inside the preview.

The lookbook is the visual regression surface for every component in the catalog — ensure lookbook stories for new components exercise their focused and unfocused states so color regressions are caught before they reach production.

Render primitives: modal_block (crates/jackin-tui/src/components/panel.rs), breadcrumb_title (crates/jackin-console/src/tui/components/op_picker.rs), render_fatal (crates/jackin-console/src/tui/components/op_picker.rs), render_filter_input (crates/jackin-tui/src/components/filter_input.rs), render_picker_list (crates/jackin-tui/src/components/select_list.rs), and render_scrollable_block_at (crates/jackin-tui/src/components/scrollable_panel.rs) — use these building blocks when composing new modals rather than hand-rolling border + layout from scratch.

One picker-list renderer, with full-width section dividers

Every modal list — the host console pickers and the in-container capsule menu/pickers — draws its rows through the shared picker renderer in select_list (crates/jackin-tui/src/components/select_list.rs). Full modal pickers use render_picker_list with PickerRow values; richer host pickers that own their own filter/state flow must still pass neutral, unselected Line rows to render_picker_lines. Callers must not pre-style selection, paint their own cursor, or stop the selected background at the text width. The shared renderer owns the selected-row gutter, full-width highlight, selection-follow viewport, and the rule that scrollbar gutter cells win over highlight cells.

There are exactly two full-picker row kinds, and a caller must not hand-roll either:

  • PickerRow::Item — a selectable row. The selected row gets the canonical highlight (PHOSPHOR_GREEN background, black bold text, cursor) in a reserved 2-column gutter, so item labels align whether or not they are selected.
  • PickerRow::Separator(label) — a non-selectable ──── label ──── section divider. A section divider must span the full inner width, border to border, with the label centered. render_picker_list repaints separator rows edge-to-edge over the selection gutter so the dashes reach both borders symmetrically — the divider is the one row that deliberately ignores the gutter. Dashes are PHOSPHOR_DARK, the label is dim. A divider that is indented on one side (gutter-shifted), shorter than the inner width, or whose label is not centered is a violation. Pass the bare label text ("agents", "shells") — never pre-build the dashes at the call site, or the width math drifts from the gutter.

One scrollbar renderer

Every scrollbar in jackin' — host console panels, dialogs, and capsule pane scrollback — must render through the shared scrollable_panel functions (crates/jackin-tui/src/components/scrollable_panel.rs): render_vertical_scrollbar* / render_horizontal_scrollbar, ScrollbarStyle::Line () as the vertical thumb, SCROLLBAR_TRACK (·) as the track, and the shared DIALOG_SCROLL_THUMB / DIALOG_SCROLL_TRACK colors. A hand-painted thumb loop (a column, a custom glyph, or per-surface colors) is a review-blocking violation — the same rule as the one picker-list renderer above. Tail-relative surfaces (capsule pane scrollback, where offset 0 means "live tail") bridge to the component's top-relative offsets with jackin_tui::scroll::TailScroll::to_top_offset over a content length of filled + viewport, and map track clicks back through scrollbar_offset_for_track_position so click-to-jump and the painted thumb share one geometry.

Two-button choice pattern

scope_picker.rs, source_picker.rs, agent_choice.rs, 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

Every "ask the operator to type one value" prompt MUST use the shared text_input dialog (crates/jackin-tui/src/components/text_input.rsTextInputState + render_text_input, or render_labeled_text_input_dialog when the dialog title and field label are distinct). That render fn draws the whole dialog: a single bordered box, the label/title, the input row, the dim input band, and the cursor styling. It is the box behind "New global environment key", the credential entry, the launch credential prompt, the workspace-name prompt, capsule Rename tab, and the op_picker Create-mode naming stages. Do not hand-roll a second input box, and do not wrap text_input::render_text_input inside another bordered block (that produces a box-inside-a-box). The dialog is a single box sized by the shared text-input rect helper for that surface, with the footer showing the active commit/cancel hints — 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.

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.

Toasts are state feedback, never action hints

Transient state feedback such as Selection copied uses the shared Toast overlay (crates/jackin-tui/src/components/toast.rs). A toast is non-focusable, appears above the caller's reserved footer/status rows, and expires through the owning surface's state timer. Do not put copy-success or other state feedback in the hint/footer row: that row is reserved for actions currently available in the focused area.

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


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.

Rules

  • 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

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.

The root console run loop that drives the wizard lives under crates/jackin/src/console/tui/run.rs; it suspends and resumes the terminal while external minting and write validation run through non-TUI services.

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 crates/jackin-console/src/tui/op_picker.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

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

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.

On this page