REPL Frame
The Frame Standard
Section titled “The Frame Standard”Every REPL view is rendered inside the suctl REPL frame. The frame is owned entirely by core. Module authors write capabilities and surface declarations — zero UI code.
The REPL is a page stack. The operator lands on the HOME page; selecting a module row pushes that surface’s SURVEY page; selecting a subject pushes the FOCUS page; selecting a drill chip on a subject row pushes the child surface’s SURVEY page scoped to that subject; esc pops back one level. There is no tab bar.
HOME — the landing page:
suctl v0.1.0 3 active · 2 ready prod-01 · 10.0.0.1 title line┌──────────────────────────────────────────────────────────┐│ ⌕ filter │ filter row│ ‹ has-warnings › │ facet row├──────────────────────────────────────────────────────────┤│ nginx ● ok │ module row, line 1│ 8 domain │ module row, line 2│ fail2ban ● warn ││ 2 jails with active bans │├──────────────────────────────────────────────────────────┤│ inventory jobs messages refresh quit │ exit row│ ↑↓ move ⏎ enter ←→/tab fields type filter │ footer (primary)│ Alt+f filter Alt+q quit │ footer (alt)└──────────────────────────────────────────────────────────┘Selecting a module row pushes that surface’s SURVEY page:
suctl v0.1.0 nginx prod-01 · 10.0.0.1 title line┌──────────────────────────────────────────────────────────┐│ ⌕ filter 3 of 12 │ filter row (+ badge)│ ‹ ● active ssl expiring suspended › │ facet row (if declared)├──────────────────────────────────────────────────────────┤│ name status ssl php │ table header│ client-a.com active 82d 8.2 │ subject rows│ client-b.com active warn 8.1 │├──────────────────────────────────────────────────────────┤│ ssl: 2 expiring │ summary row│ ← home refresh │ exit row│ ↑↓ row ←→/tab fields ⏎ focus or run esc home │ footer└──────────────────────────────────────────────────────────┘Selecting a subject pushes the FOCUS page:
suctl v0.1.0 nginx prod-01 · 10.0.0.1 title line┌──────────────────────────────────────────────────────────┐│ nginx › client-a.com ← survey │ breadcrumb header├──────────────────────────────────────────────────────────┤│ RUNTIME STATE ││ status ssl certificate │ two-column grid│ active valid · 82d remaining ││ ││ vhost config (full width) ││ /etc/nginx/… │├──────────────────────────────────────────────────────────┤│ AVAILABLE ACTIONS ││ [suspend] [renew ssl] [reload nginx] [tail logs] │ actions footer│ ←→/tab action ↑↓ scroll ⏎ invoke esc back │ footer└──────────────────────────────────────────────────────────┘Frame Anatomy: Source of Each Element
Section titled “Frame Anatomy: Source of Each Element”Title Line
Section titled “Title Line”The single line above the box on every page. Three bands.
| Element | Source |
|---|---|
| Left band | Static suctl label + version string |
| Middle band | Page-supplied. HOME: inventory counts N active · M ready (warn-colored when no module is active). Survey/focus: the surface’s short name, with · loading… appended while a call is in flight. |
| Right band | Core probe at startup: hostname · ip |
Home Page
Section titled “Home Page”The landing page: one row per surface of each active module that has a surface.json. A module declaring several surfaces fans out into one row per surface — the first keeps the module’s bare short name, each subsequent one is name:subject. Rows follow the deterministic order delivered by system.module.inventory.
Each module row is two lines:
| Element | Source |
|---|---|
| Line 1 — name | Module short name (directory-derived, suctl-mod- prefix stripped) |
| Line 1 — health glyph | Spinner + loading… while the survey call is in flight; ✗ error (red) when the survey failed; ● warn (amber) when status_summary is non-empty; ● ok (green) otherwise |
| Line 2 | Amber status_summary from the survey response; dim bare-count fallback (%d {subject}) when empty and total > 0; blank otherwise |
The chrome rows:
| Row | Source |
|---|---|
| Filter row | Client-side match against module short names. No capability call. |
| Facet row | Single built-in has-warnings facet — shows only rows with a non-empty status_summary or an error |
| Exit row | inventory, jobs, messages, quit buttons. Inventory, Jobs, and Messages are core’s embedded module, job, and message surfaces — they open as ordinary survey pages. |
Navigation: ↑↓ moves between module rows, ⏎ pushes the selected surface’s survey page, ←→/tab move between fields and buttons, typed text filters, Alt+q quits.
Survey Page
Section titled “Survey Page”Pushed onto the stack when the operator selects a home-page row. Entry always triggers a fresh survey call — the page reflects live state. The title line middle band shows the surface’s short name.
Filter Row
Section titled “Filter Row”| Element | Source |
|---|---|
| Placeholder text | Static "filter" prompt |
| Filter behaviour | Frame-side: text matched against all visible column values in the currently displayed rows. No capability call. Instant. |
| Filter badge | "N of M" right-aligned in the filter row — N is the count of visible rows after matching, M is total from the survey response. Shown only when filter text is non-empty; hidden otherwise. |
Facet Chips
Section titled “Facet Chips”| Element | Source |
|---|---|
| Chip labels | surface.json survey.facets[].label |
| Active chip indicator | Frame internal state |
| Facet behaviour | Active chips’ value fields collected into the facets array param for the next survey capability call. Multiple active facets combine with AND semantics — the module returns only subjects that satisfy every active facet value. Frame re-calls survey, replaces subject list, re-renders. |
An empty facets array (no chips active) means no active facet — the module returns all subjects. There is no required “all” sentinel chip.
| Element | Source |
|---|---|
| Name column header | Static "name" label. Always first. Always visible. |
| Name column value | Survey response subjects[].name |
| Additional column headers | surface.json survey.columns[].label, in declared order |
| Additional column widths | Auto-sized from max(label, values) unless surface.json survey.columns[].width is set (explicit cap) |
| Additional column alignment | surface.json survey.columns[].align — "left" (default) or "right" |
| Column cell value | Survey response subjects[].columns["<id>"].value |
| Column cell color | Survey response subjects[].columns["<id>"].color — see Color Tokens |
| Missing column cell | Rendered as — in dim color when a subject’s columns object omits a declared column id |
Selection indicator ▶ | Frame internal: cursor row |
Row arrow › | Shown on selected row only |
Drill Chips
Section titled “Drill Chips”When the parent surface’s surface.json declares drills[], each subject row carries one chip per drill child — rendered exactly like an inline-action chip (a per-row button on the right of the row). Activating a drill chip pushes the child surface’s SURVEY page onto the stack and passes the selected row’s opaque id as the scope argument to the child’s survey capability.
| Element | Source |
|---|---|
| Chip label | surface.json drills[].label (defaults to drills[].subject when omitted) |
| Chip placement | Right side of the subject row, alongside any inline-action chips |
| Activation | Pushes the child surface’s survey page; the survey call receives { "scope": "<row-id>" } |
esc behaviour | Pops one drill level back to the parent survey, which re-reads live state |
Summary Row
Section titled “Summary Row”An in-box row rendered below the subject table, above the navigation buttons. Always drawn (stable chrome height — the row is never absent). Sources from the survey response, not from free-text filter activity.
| Element | Source |
|---|---|
| Module summary (amber) | Survey response status_summary when non-empty — module author’s operational voice for the active facet scope |
| Bare count fallback (dim) | "%d %s" using total and surface subject when status_summary is empty and total > 0. Example: 12 domain. |
| Empty row | When total is 0 or subject is absent — one blank row preserving chrome height stability |
The summary row is not affected by the free-text filter. It reflects the module’s scoped status for the active facet set.
Exit Row
Section titled “Exit Row”An in-box button row below the summary row.
| Element | Source |
|---|---|
← home button | Pops back to the home page |
refresh button | Re-runs the survey call with the current facet set |
show system / hide system toggle | Jobs page only — passes show_system as a survey param so the server applies the filter |
Focus Page
Section titled “Focus Page”Pushed when the operator selects a subject row in the survey table. Popping back (esc) returns to the survey page, which re-reads live state.
Focus Header
Section titled “Focus Header”| Element | Source |
|---|---|
| Breadcrumb module | The surface’s short name |
| Breadcrumb separator | Static › |
| Breadcrumb subject | Selected subjects[].name from survey |
← survey hint | Right-aligned dim hint — esc pops back; it is not a focusable button |
Focus Body
Section titled “Focus Body”| Element | Source |
|---|---|
| Section list | Focus response sections[] — rendered in declared order |
| Section title | sections[].title — rendered as uppercase label |
| Field label | sections[].fields[].label |
| Field value | sections[].fields[].value |
| Field color | sections[].fields[].color — see Color Tokens |
| Full-width field | sections[].fields[].full_width: true — spans both grid columns |
| Two-column layout | Default: fields fill a two-column grid within each section |
Actions Footer
Section titled “Actions Footer”| Element | Source |
|---|---|
| Section title | Static "available actions" |
| Action label | Focus response actions[].label |
| Action color | Focus response actions[].color — see Action Button Color Tokens |
| Action capability | Focus response actions[].capability |
| Empty state | When actions is empty: "no actions available" in dim |
The operator navigates action buttons with left/right arrow keys. Pressing enter invokes the capability. After invocation: frame closes focus, returns to survey, and triggers a fresh survey call.
Footer Hints
Section titled “Footer Hints”There is no status bar. Each page renders one or two in-box hint lines directly above the bottom border. Keys wear a keycap chip (viewHintRow) so they read as bindings rather than prose.
| Page | Hint line(s) |
|---|---|
| HOME (primary) | ↑↓ move · ⏎ enter · ←→/tab fields · type filter |
| HOME (alt) | Alt+f filter · Alt+q quit |
| SURVEY | ↑↓ row · ←→/tab fields · ⏎ focus, drill, or run · esc back · type filter |
| FOCUS | ←→/tab action · ↑↓ scroll · ⏎ invoke · esc back |
Note:
status_summaryfrom the survey response is never rendered in a footer. It renders in the in-box Summary Row (survey page) and as a module row’s second line (home page).
For survey and focus response shapes and color tokens: surface schema.
UI Rendering Layers
Section titled “UI Rendering Layers”The REPL rendering stack has three named layers. The boundary between them is a hard rule: if code formats a character, it belongs in atoms or compounds; if code decides what to show and where, it belongs in a page.
Layer 1 — Atoms (internal/theme + glyph constants in internal/repl/ui.go)
Section titled “Layer 1 — Atoms (internal/theme + glyph constants in internal/repl/ui.go)”The smallest indivisible rendering units. No caller constructs colors or styles inline. All palette values, semantic mappings, and raw glyphs live here.
| Atom type | Definition | Example |
|---|---|---|
| Palette colour | theme.Theme.{Name} struct field | theme.Red, theme.GreenDim |
| Named role style | theme.Theme.{Role} struct field | theme.Body, theme.BtnSelected |
| Semantic cell style | theme.Status(token) | theme.Status("ok") → green FG |
| Glyph constant | repl.Glyph{Name} in ui.go | GlyphGutter, GlyphCursor |
Glyph constants — no raw Unicode literals anywhere except these declarations:
| Constant | Glyph | Role |
|---|---|---|
GlyphGutter | ▎ | Selection gutter marker — left edge of selected row |
GlyphCursor | █ | Text-input cursor |
GlyphCaret | › | Row prefix · breadcrumb separator · facet-strip close bracket |
GlyphFacetOpen | ‹ | Facet-strip open bracket |
GlyphSep | · | Inline dot separator between items in a row |
GlyphLive | ● | Live-read data source indicator |
GlyphSelect | ▶ | Row-selection marker |
Layer 2 — Compounds (internal/repl/ui.go)
Section titled “Layer 2 — Compounds (internal/repl/ui.go)”Composed widgets built from atoms. Each compound has exactly one implementation. Page files never construct layout strings directly — they call a compound function.
| Compound | Function signature | Purpose |
|---|---|---|
| Box border | boxTop / boxMid / boxBot | Box-drawing frame lines |
| Row | bRow(content, inner) | One inner row with border chars and ANSI-aware right-padding |
| Selected row | bRowSel(content, inner) | bRow + GlyphGutter gutter marker + highlight background + bold |
| Cell | renderCell(val, color, width, align) | Single column cell — prepends GlyphAlert for "alert" tokens; FG-only style for all tokens |
| Filter row | viewFilterRow(text, onRow, inner, badge) | Interactive text-filter input row; badge (e.g. "3 of 12") right-aligned when non-empty |
| Facet row | viewFacetRow(facets, activeIdx, onRow, inner) | Facet chip toggle strip |
| Title line | viewTitleLine(inv, hostname, ip, w, tb) | Top-of-frame title: suctl label · middle band (tb.Middle, fallback to inventory counts) · hostname |
| Keycap hint | kbdHint(key, desc) | One key+description pair: key in raised-background chip (Bg4/Text1), description in dim text |
| Hint row | viewHintRow(pairs...) | Joins kbdHint pairs with stable inter-pair gaps into a single footer-hint line; used by all page footer rows |
Alert rendering: renderCell prepends GlyphAlert + " " (✗ ) when color == "alert", then applies bold red FG across the whole "✗ value" string. This relies on glyph shape + colour rather than background colour, which is portable across all terminal emulators and colour schemes. computeColumnLayout reserves +2 columns for the glyph prefix when auto-sizing alert columns.
Layer 3 — Pages (internal/repl/page_*.go)
Section titled “Layer 3 — Pages (internal/repl/page_*.go)”Each page is a tea.Model that assembles compounds into a full screen string via its View() method. Pages own layout decisions — which rows appear, in what order, what state drives them — but not rendering details. The current pages:
| File | Page | Entry point |
|---|---|---|
page_home.go | Home — active-module rows + inventory/jobs/messages/quit | initial page on the root.go stack |
page_survey.go | Per-surface survey table | selected home row · drill chip on parent survey row |
page_focus.go | Subject detail + actions | row selection |
page_confirm.go | Destructive-action confirmation | destructive action |
page_cascade_confirm.go | Cascade-activation confirmation (D48) | system.module.activate returns CONFIRMATION_REQUIRED |
page_running.go | Async job progress | action invocation |
page_result.go | Action outcome | job completion |
suctl ships a single built-in theme (dark). It controls every colour in the REPL frame, installer output, and boot prompt. The color token vocabulary ("ok", "warn", "err", …) is defined once in internal/theme.
| Theme | Description |
|---|---|
dark | Dark background (#0b0d12), muted blue/green/amber accents. |