Dialogs & Modals
Modal sizing rules, confirmation layouts, sub-dialog stacking, error surface rules, wizard flows, hints/footer bar, env sentinel indentation, and settings/editor parity.
Modal Sizing Rules
Modal sizes are product policy expressed as TermRock DialogSpec values. The console and Capsule registries retain named product modes and construct min/preferred/max dimensions, independent margins, and placement; all neutral centering, top placement, clamping, and narrow-terminal resolution runs through TermRock resolve_dialog. 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.
Stable preferred dialog size — resize does not rescale
Dialog width is a stable preferred size derived from pct_w% of a 160-column reference terminal, capped at outer.width - 4 margin. The dialog holds that width whenever the terminal is at least that wide and only shrinks when the terminal is genuinely too narrow.
modal_rects::centered_rect_fixed(outer, pct_w, rows) implements this policy for fixed-reference dialogs, and registry specs cover the launch and capsule variants that intentionally use different width policies. Never compute dialog width ad hoc in surface code — that hides sizing policy outside the registry and makes resize behavior drift.
Status bar always reserved — modals never draw over it
The reserved status/hint rows at the bottom of every screen are inviolable: no modal, dialog, or border may draw onto them. All modal rects are computed against the content area (full terminal minus footer height), not the full terminal area.
prepare_visible_modal() subtracts footer_height before centering modals. Every new modal computation must use the content area, not the raw terminal size, then pass that reserved area through the shared registry.
Text input dialogs use shared widgets
Text-input dialogs are shared components, not surface-local drawings. A one-label prompt uses TextInputState + render_text_input from TermRock source and computes its outer area with text_input_prompt_rect. A prompt whose box title differs from its field label, such as capsule Rename tab with field Name, uses render_labeled_text_input_dialog from the same file.
Surface code may still compute the outer dialog rectangle and footer hints, but it must not draw its own text-input border, title, label row, input band, or cursor styling. If a surface needs a different title/label combination, add parameters to the shared helper instead of copying the renderer.
Symmetric vertical padding for every dialog
Every dialog uses the canonical symmetric inner layout:
┌ Title ──────────────────────────────┐
│ │ ← 1 leading spacer row
│ content │ ← 1+ content rows
│ │ ← 1 spacer row
│ Save · Discard · Cancel │ ← action/button row
│ │ ← 1 trailing spacer row
└──────────────────────────────────────┘Rules:
- Exactly one blank leading spacer row between the top border and the first content row.
- Exactly one blank spacer row between the last content row and the action/button row.
- Exactly one blank trailing spacer row between the action row and the bottom border.
- No two trailing blank rows, no missing leading blank row.
This applies to ConfirmDialog, ErrorDialog, SaveDiscardDialog, StatusPopup, and any other modal that contains content + action rows. required_height() for each dialog must account for all five layers (border×2 + leading + spacer + trailing = 5 overhead rows beyond content + action).
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 (crates/jackin-console/src/tui/components/role_picker.rs and crates/jackin-console/src/tui/components/op_picker.rs) and the in-multiplexer pickers (crates/jackin-capsule/src/tui/components/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.
Dialog button spacing — symmetric leading and trailing spacers
Every dialog that contains a button row (OK, Yes/No, Save/Discard/Cancel, etc.) must follow the canonical five-slot inner layout described above under "Symmetric vertical padding." The old rule of "one empty row before buttons" was incomplete — the updated rule requires both a leading spacer before the first content row and a trailing spacer after the button row.
│ │ ← leading spacer
│ Content line │
│ │ ← spacer before buttons
│ Yes No │ ← button row
│ │ ← trailing spacer
└───────────────────────────────────────┘This applies to all dialog surfaces: the host console confirm dialog, the launch failure popup, the capsule confirm action dialog, and any future dialog that renders buttons.
Implementation: In height calculations, use content_rows + 6 (= 2 borders + 1 leading spacer + 1 spacer + 1 button + 1 trailing spacer). For any new dialog: compute the exact row count and add exactly 6 overhead rows. Compose the body with TermRock layout::bottom_rows (for example bottom_rows(inner, [1, 1, 1]) for mid-spacer / actions / trailing spacer, then split the remaining body into a leading spacer plus content). Console surfaces that still share the historical five-row body can call jackin_console::tui::dialog_layout::dialog_content_and_actions — a thin product composition helper on top of bottom_rows, not a TermRock compatibility facade.
Universal dialog scroll rule: No dialog may silently clip content. When a dialog's body cannot fit in the dialog area, it must scroll on the overflowing axis with a scrollbar on the matching border — vertical on the right, horizontal on the bottom — and the bar appears only when content actually overflows that axis. Both axes go through one shared mechanism in TermRock source: a DialogScroll field (scroll_x + scroll_y) plus theme-explicit helpers such as render_scrollable_dialog_body(frame, block_area, content_area, &lines, &mut scroll, &theme) or the canonical Viewport widget, which render with both-axis offset and draw scrollbars when overflowing. Keyboard scroll is ↑/↓/j/k (vertical) and ←/→/h/l (horizontal); the mouse wheel scrolls both axes. Never hand-roll per-row clipping or a bespoke scrollbar — route the body through TermRock scroll primitives.
Wheel routing is per-surface but uses one neutral handler. Each surface has its own input loop, so each must decode terminal events and call TermRock DialogScroll::handle_mouse; backends that still hold raw SGR/bytes first translate them in their surface-local TUI input adapter. Shared widget state never parses escape sequences. Two non-obvious requirements that this rule exists to enforce:
- A dialog must not swallow wheel events. The capsule's input dispatch previously dropped every non-left-click press while a dialog was open, eating the wheel before it could scroll the body; the dialog-open guard must let wheel buttons through to the scroll handler.
- Every surface that opens a scrollable dialog wires the wheel. All three input loops route the wheel to the open dialog's scroll state: the console manager (
crates/jackin-console/src/tui/input/mouse.rs), the launch cockpit (crates/jackin-launch/src/tui/subscriptions.rs), and the capsule daemon (crates/jackin-capsule/src/tui/daemon/input_dispatch.rs). Adding a new surface that hosts a scrollable dialog means wiring its wheel the same way.
DialogScroll::handle_mouse treats ScrollLeft/ScrollRight as horizontal and Shift+ScrollUp/ScrollDown as horizontal, because some terminals map a horizontal trackpad swipe onto a shifted vertical wheel rather than emitting native horizontal-wheel events.
ContainerInfoState (the shared "Debug info" dialog) is the reference implementation: long identity values scroll horizontally instead of clipping, and its click-to-copy hit-test follows the content under both scroll axes. Surfaces that rebuild their dialog state every frame (the Capsule and launch cockpit) persist the DialogScroll outside the rebuilt state — on the Capsule's Dialog enum variant and the cockpit's LaunchView — and thread it into the rebuilt ContainerInfoState.
Debug info dialog contract
DebugInfo::into_state() in the product facade is the only row-order and row-label source for Debug info. It projects into TermRock DetailTable, the single neutral renderer for shell, values, copy/link capabilities, hover styling, visible regions, and both-axis scroll. No surface may fork either layer.
The canonical row order is:
Invocation ID— copyable, always the top row whenever available.Container ID— copyable.jackin version.jackin-capsule.Role.Agent.Target.Telemetry— optional, non-copyable sanitized delivery summary on the launch surface.
Invocation ID is the opaque correlation value for backend searches. No local telemetry or diagnostics-file path row exists. Enter copies the first copyable row in canonical order, so it copies Invocation ID whenever that row exists, and the dialog stays open so copied-row feedback can render. Mouse click copies the copyable value under the pointer; hover feedback applies only to copyable value cells and their copy affordance.
Debug info is status-preserving only for reserved bottom chrome: if the underlying surface had a footer/status row before the dialog opened, that chrome remains visible and is still rendered by its normal owner. The dialog is centered in the content area that excludes reserved status/footer rows, not the full terminal area. Inside that content area, Debug info owns the modal body and clears the background to the terminal default background before rendering the panel, so pane/list/card content, focused borders, scrollbars, and animated body content behind it are hidden rather than dimmed or left readable.
Wrap vs scroll: prose dialogs (error popup, status popup) wrap long text with Wrap — wrapped text never overflows horizontally, so no horizontal bar appears, and that is correct. Horizontal scroll is for structured/data bodies (label/value info rows, paths, URLs) where wrapping would mangle a single value; those route through render_scrollable_dialog_body and scroll. Both satisfy the no-clip rule.
Read-only pane text selection copies and persists
Capsule pane content is read-only from jackin❯ point of view. Mouse selection has exactly one product action: copy the selected text to the outer clipboard through OSC 52. Do not add edit, cut, delete, replace, or paste semantics to pane selection.
Completed drag selections remain visibly highlighted after mouse-up. Mouse-up copies the selected text and shows transient copied feedback in a small overlay, so the operator can see both what was copied and that the clipboard write happened without replacing the action hints. The overlay is the shared Toast component, anchored near the top-right of the visible surface. It is non-modal: it does not take focus, does not rewrite the hint bar, does not hide the retained selection, and expires on a deterministic short timer. Never put this state feedback in the hint bar; footer hints are only for currently available actions. The persisted selection clears only on an explicit deselect action: a later ordinary click, typing input, or starting a new selection. A mouse-up that completes the copy is not itself a deselect action.
Dragging outside the top or bottom of the pane auto-scrolls retained scrollback in that direction and extends the selection. Selection motion owns that pointer event; it must not also forward the same event to the pane PTY.
Confirmation dialogs use the canonical Yes/No layout
Every Yes/No confirmation rendered on any jackin❯ TUI surface — host console (TermRock source) and the in-container multiplexer (crates/jackin-capsule/src/tui/components/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 =
Nofor destructive confirmations. Confirms usually exist because the action is destructive; Enter on a freshly-opened destructive confirm must never fire the destructive arm. The operator has to deliberately Tab / Right / Y to commit Yes. The plain hostExit jackin❯?confirmation is the exception: it defaults toYesbecause the operator already invoked the explicit quit chord, and the action is immediate exit rather than delete/discard of dirty state. - 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 [TermRock source](https://github.com/tailrocks/termrock) (ConfirmState + render). In-container confirms render through the daemon's render_confirm_action in crates/jackin-capsule/src/tui/components/dialog_widgets.rs, which wraps that same shared widget; 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.
Exit confirmation: one wording, Ctrl+Q opens it, Ctrl+C bypasses it
Quitting jackin❯ shows the same "Exit jackin❯?" confirmation on every surface, built from one place. The canonical builders live in TermRock source: exit_confirm_state() (the plain prompt) and exit_confirm_state_with_data_loss() (the prompt plus warning notes). No surface hand-writes the wording. The host console's quit_confirm_state() delegates to exit_confirm_state(); the launch cockpit opens it as an overlay; the in-container multiplexer's render_confirm_action builds the data-loss variant for its Exit confirm.
Ctrl+Q is the universal quit chord. It opens the exit confirmation on the console, the launch cockpit, and the capsule — regardless of screen or focus, since it is a control chord rather than a text character. The console also accepts a bare q off the main screen as a convenience, but Ctrl+Q always works. The plain host exit confirmation defaults focus to Yes, so Enter follows the operator's explicit quit intent; destructive data-loss confirmations still default to No.
Ctrl+C is not a quit confirmation — it is an immediate hard exit. On the launch cockpit it aborts the in-progress launch at once; on the console it quits at once. Either way there is no dialog, and it wins even when the exit confirmation is already open. Ctrl+Q asks first and is reversible (No resumes); Ctrl+C does not ask. (In the capsule, Ctrl+C belongs to the focused agent's PTY as SIGINT and is not intercepted; quit there is Ctrl+Q.)
Confirmed quit and Ctrl+C both exit immediately in the launch cockpit. In the launch cockpit, Ctrl+Q opens the confirmation, and choosing Yes is a hard stop: the terminal is restored and the process exits immediately. It runs no cleanup and waits on no in-flight work (a slow docker build, a spawn_blocking binary download). Ctrl+C bypasses the confirmation and takes the same hard-stop path. Any docker resources left behind are reclaimed by the next launch's gc_orphaned_resources. This is why these exits cannot hang on a slow stage: they do not wait for anything.
Double Ctrl+C is the unconditional escape hatch. The launch surface owns terminal input on a dedicated OS thread outside the async renderer. A second Ctrl+C within the short double-press window restores the terminal and exits the process directly, without needing the render task, launch pipeline, cancellation token, or a UI mutex to make progress. This applies while a build is running and while any launch modal/prompt is open.
The exit confirmation preserves the bottom chrome. The dialog dims only the body and centers in it; the hint row, the blank separator, and the status bar render beneath it in the usual order, so the status bar (always present in --debug, per chrome) is never hidden by the dialog. This is the same overlay shape every launch overlay uses.
The capsule's exit confirmation carries extra warning notes because quitting there force-stops the container and reaps every agent immediately — work not persisted outside the container is lost. It therefore uses exit_confirm_state_with_data_loss() (prompt + notes) instead of the plain prompt; the box is sized from the shared confirm_required_height so the notes are never clipped. Confirming routes to the same ExitAllSessions teardown the command palette's Exit item already used.
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: dismisses the dialog (same as Esc). This rule applies to all surfaces — host console, launch cockpit, and capsule — not only the capsule's dialog stack. Clicking inside the dialog body where there is no interactive element is a no-op (does not dismiss). Implementation: use
termrock::interaction::classify_click(modal_rect, col, row);OutsideDismisstriggers dismiss, whileInsideHitis consumed by the modal and does not propagate to underlying chrome. - 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.
The shared mechanism for this contract is TermRock's atomic FocusRing + ModalStack lifecycle, projected into product state by jackin_tui::runtime::ModalFlow. open_sub preserves the visible product modal while opening its matching focus scope, pop restores both parent and scope for Esc/cancel, and clear closes the whole chain after a terminal commit. Product code tests its modal transitions through ModalFlow; TermRock owns the primitive focus/stack conformance tests.
Settings uses one SettingsModal union for global mounts, environment, and auth dialogs. Tab-specific render and input helpers accept that same modal type and treat a wrong-tab variant as a routing bug, so shared modal behavior has one container shape instead of three parallel enum families.
The capsule's product dialog stack in crates/jackin-capsule/src/tui/components/dialog.rs owns pane/session-specific dialog values, while shared focus-scope mechanics remain TermRock-owned. It must preserve the same open/pop/clear semantics and must not reimplement neutral focus traversal.
Modal-to-modal flows carry return context in the modal variant or the shared stack frame, not in a side slot. Environment pickers carry their scope/key/value context on modal variants, and auth-form source/picker flows return through the modal parent stack. A bare Option<Modal> slot is not a valid shape for any flow that can open a child modal, because it cannot represent the parent under the child.
Compliance is covered by focused tests for the container palette stack, editor environment-source and mount-destination chains, settings environment chains, settings global-mount add chains, and the jackin❯ ModalFlow projection. Treat new Esc-back regressions on any surface as bugs; fix the product flow through the shared lifecycle rather than adding a new stash.
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.
This includes modals. A modal is not a special case — when one is open, its keys replace the screen keys in the same reserved footer rows. There is no floating hint bar under a dialog; hints always live in the fixed footer. Two rules make this un-forgettable and keep it visible:
- Exhaustive matcher. Every modal family maps to its hints through an exhaustive
match(e.g.console::tui::components::footer::modal::modal_footer_itemsoverConsoleModal, and thesettings_*_modal_footer_itemssiblings). Adding a modal variant without a hint arm is a compile error — a new dialog cannot ship hint-less. Screen footer selectors (workspace_footer_items,editor_footer_items,settings_footer_items) return the active modal's items when a modal is open. - Keymap-derived modal hints. Dialogs with an input keymap must derive their visible footer spans from that same
Keymap, then append dynamic scroll hints from the body's real overflow axes. The console save-confirmation preview follows this rule: Save, Cancel, Enter/select, focus movement, and preview scroll dispatch throughCONFIRM_SAVE_KEYMAP, and its footer is built from that table plusSCROLL_HINT_KEYMAP. - Backdrop never covers the footer. The modal backdrop is rendered over the screen minus the reserved footer rows, so the modal-aware footer stays visible beneath the dialog. The footer is inviolable; a backdrop that fills the whole area (hiding the keys) is a violation.
The in-container capsule follows the same contract via its own exhaustive Dialog::footer_hint_spans rendered in the bottom chrome.
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
+ Add environment variable sentinel at workspace level must align with workspace-level content (2-char cursor gutter only). It must not pick up the 5-space marker column 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
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.
Shared settings/editor row families live in crates/jackin-console/src/tui/components/editor_rows.rs. Auth rows use one AuthLineRow model and renderer, env/secrets rows use one line builder with panel-specific adapters, and simple label/value rows use labeled_field_line. Mount table headers route through the shared mount-row helpers. Do not add a settings-only or editor-only renderer for a row kind that already exists in the other panel; extend the shared row model instead.
Parity is test-backed for the common auth rows: equivalent settings and editor rows must render to identical text. Add cases to that parity test whenever a new shared row kind appears in both panels.
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 asfocusedto the TermRockViewport/ consolescroll_blockadapter. No tab may hardcodefocused = false. - Vertical scroll state: every tab that can overflow vertically must have a
scroll_ystate field that the scrollable renderer consumes. 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
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 (TermRock Viewport via the console scroll_block adapter, plus DialogScroll / render_scrollbar) enforces 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.
Long values in dialogs — truncation is a design violation
Any dialog or info panel that displays a long value (file path, container ID, URL, error message) must not silently truncate it with no way for the operator to see the full content. Three acceptable approaches, in priority order:
-
OSC 8 hyperlink (preferred for file system paths and URLs). Render the path as a terminal hyperlink via
]8;;file:///full/path\visible text]8;;\. The operator clicks to open the file. The visible text can be abbreviated; the href carries the full path. Telemetry correlation values are copy targets rather than file hyperlinks because jackin❯ creates no local telemetry artifacts. -
Word-wrap onto multiple lines. If the value is a long string (not a path), wrap it within the dialog width.
ErrorPopupowns the row wrapping, height estimate, scroll offset, and value-cell rectangles so render, OSC 8 overlays, and copy/reveal hit-testing stay aligned. -
Horizontal scroll. Only use this when neither hyperlink nor wrap is feasible. Use TermRock
Viewport/DialogScrollso the shared scrollbar and clamp math apply.
Never silently truncate: path.display().to_string().chars().take(N).collect() with no affordance is a design violation. If the operator cannot access the full value, the dialog is broken.
Error Surface — ErrorPopup Only, No Ephemeral Overlays
All error conditions surfaced to the operator must use the red-border ErrorPopup dialog (TermRock source). 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.
ErrorPopup may include structured label/value rows with OSC 8 hyperlink targets for paths and URLs; use this mode for long diagnostic values per the long-values rule above.
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 for commit-style actions. Navigation back to the workspace list after a successful save is sufficient confirmation. No success toast, no success popup.
Copy feedback is the exception because copy does not navigate or otherwise change the screen. A successful copy may show the shared non-blocking Toast overlay, outside the hint/footer row, long enough for the operator to see that the clipboard write happened.
Debug info dialog — one shared, accumulating component
The "Debug info" dialog has one product facade and one neutral renderer. jackin❯ operator info owns DebugInfo, row order, labels, diagnostics policy, clipboard/open effects, and final OSC emission. TermRock DetailTable and Panel own rendering, scrolling, copy/link capabilities, hover, copied affordances, and typed hit regions. Console, launch, and Capsule only supply the facts available at their lifecycle stage.
The model accumulates as facts become known. Each surface fills only the fields it knows and calls DebugInfo::into_state; absent fields are omitted. The console knows the jackin❯ version and invocation ID. The launch cockpit additionally knows the container id, role, agent, target, and optional sanitized telemetry delivery summary. The capsule additionally knows its own binary version. Canonical row order: Invocation ID, Container ID, jackin❯ version, jackin-capsule, Role, Agent, Target, Telemetry. If Invocation ID is present, it is always the first visible row and the default Enter-copy target.
Version rows must match the CLI exactly. The jackin row is the jackin --version string and the jackin-capsule row is the jackin-capsule --version string. Because those values come from build-time env vars (JACKIN_VERSION, JACKIN_CAPSULE_VERSION) that are only in scope in the binary crates, the model takes them as data — pass the exact CLI string, never the bare crate version.
Backdrop. Debug info is the status-preserving modal exception only for reserved bottom chrome. It paints an opaque default-background backdrop inside the content area before rendering the panel, hiding modal body/background content behind it. Status footers and reserved bottom rows that were visible before the dialog opened remain visible and continue to be rendered by their owning surface.