Skip to content

REPL Frame


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
└──────────────────────────────────────────────────────────┘

The single line above the box on every page. Three bands.

ElementSource
Left bandStatic suctl label + version string
Middle bandPage-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 bandCore probe at startup: hostname · ip

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:

ElementSource
Line 1 — nameModule short name (directory-derived, suctl-mod- prefix stripped)
Line 1 — health glyphSpinner + 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 2Amber status_summary from the survey response; dim bare-count fallback (%d {subject}) when empty and total > 0; blank otherwise

The chrome rows:

RowSource
Filter rowClient-side match against module short names. No capability call.
Facet rowSingle built-in has-warnings facet — shows only rows with a non-empty status_summary or an error
Exit rowinventory, 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.


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.

ElementSource
Placeholder textStatic "filter" prompt
Filter behaviourFrame-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.
ElementSource
Chip labelssurface.json survey.facets[].label
Active chip indicatorFrame internal state
Facet behaviourActive 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.

ElementSource
Name column headerStatic "name" label. Always first. Always visible.
Name column valueSurvey response subjects[].name
Additional column headerssurface.json survey.columns[].label, in declared order
Additional column widthsAuto-sized from max(label, values) unless surface.json survey.columns[].width is set (explicit cap)
Additional column alignmentsurface.json survey.columns[].align"left" (default) or "right"
Column cell valueSurvey response subjects[].columns["<id>"].value
Column cell colorSurvey response subjects[].columns["<id>"].color — see Color Tokens
Missing column cellRendered 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

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.

ElementSource
Chip labelsurface.json drills[].label (defaults to drills[].subject when omitted)
Chip placementRight side of the subject row, alongside any inline-action chips
ActivationPushes the child surface’s survey page; the survey call receives { "scope": "<row-id>" }
esc behaviourPops one drill level back to the parent survey, which re-reads live state

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.

ElementSource
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 rowWhen 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.

An in-box button row below the summary row.

ElementSource
← home buttonPops back to the home page
refresh buttonRe-runs the survey call with the current facet set
show system / hide system toggleJobs page only — passes show_system as a survey param so the server applies the filter

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.

ElementSource
Breadcrumb moduleThe surface’s short name
Breadcrumb separatorStatic
Breadcrumb subjectSelected subjects[].name from survey
← survey hintRight-aligned dim hint — esc pops back; it is not a focusable button
ElementSource
Section listFocus response sections[] — rendered in declared order
Section titlesections[].title — rendered as uppercase label
Field labelsections[].fields[].label
Field valuesections[].fields[].value
Field colorsections[].fields[].color — see Color Tokens
Full-width fieldsections[].fields[].full_width: true — spans both grid columns
Two-column layoutDefault: fields fill a two-column grid within each section
ElementSource
Section titleStatic "available actions"
Action labelFocus response actions[].label
Action colorFocus response actions[].color — see Action Button Color Tokens
Action capabilityFocus response actions[].capability
Empty stateWhen 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.


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.

PageHint 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_summary from 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.


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 typeDefinitionExample
Palette colourtheme.Theme.{Name} struct fieldtheme.Red, theme.GreenDim
Named role styletheme.Theme.{Role} struct fieldtheme.Body, theme.BtnSelected
Semantic cell styletheme.Status(token)theme.Status("ok") → green FG
Glyph constantrepl.Glyph{Name} in ui.goGlyphGutter, GlyphCursor

Glyph constants — no raw Unicode literals anywhere except these declarations:

ConstantGlyphRole
GlyphGutterSelection gutter marker — left edge of selected row
GlyphCursorText-input cursor
GlyphCaretRow prefix · breadcrumb separator · facet-strip close bracket
GlyphFacetOpenFacet-strip open bracket
GlyphSep·Inline dot separator between items in a row
GlyphLiveLive-read data source indicator
GlyphSelectRow-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.

CompoundFunction signaturePurpose
Box borderboxTop / boxMid / boxBotBox-drawing frame lines
RowbRow(content, inner)One inner row with border chars and ANSI-aware right-padding
Selected rowbRowSel(content, inner)bRow + GlyphGutter gutter marker + highlight background + bold
CellrenderCell(val, color, width, align)Single column cell — prepends GlyphAlert for "alert" tokens; FG-only style for all tokens
Filter rowviewFilterRow(text, onRow, inner, badge)Interactive text-filter input row; badge (e.g. "3 of 12") right-aligned when non-empty
Facet rowviewFacetRow(facets, activeIdx, onRow, inner)Facet chip toggle strip
Title lineviewTitleLine(inv, hostname, ip, w, tb)Top-of-frame title: suctl label · middle band (tb.Middle, fallback to inventory counts) · hostname
Keycap hintkbdHint(key, desc)One key+description pair: key in raised-background chip (Bg4/Text1), description in dim text
Hint rowviewHintRow(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:

FilePageEntry point
page_home.goHome — active-module rows + inventory/jobs/messages/quitinitial page on the root.go stack
page_survey.goPer-surface survey tableselected home row · drill chip on parent survey row
page_focus.goSubject detail + actionsrow selection
page_confirm.goDestructive-action confirmationdestructive action
page_cascade_confirm.goCascade-activation confirmation (D48)system.module.activate returns CONFIRMATION_REQUIRED
page_running.goAsync job progressaction invocation
page_result.goAction outcomejob 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.

ThemeDescription
darkDark background (#0b0d12), muted blue/green/amber accents.