render

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BlockRows

func BlockRows(s string) int

BlockRows reports how many terminal rows a rendered block occupies when it is concatenated straight onto whatever follows it — which is exactly what every Frame implementation does: header, body, dialogs, composer and footer are written back to back with no separator of their own.

That makes the row count a property of the newlines, not of lipgloss.Height: a block that ends with "\n" leaves the next block starting on a fresh row, so it occupies one row per newline and lipgloss.Height (which counts the empty piece after the final newline as a row) would over-count it by one. A block that does NOT end with a newline shares its last row with whatever follows; it is counted whole, so a layout budget built on this can only ever over-reserve, never under-reserve. Both presenters' Header and Dialog end with a newline, so today the first branch is the one that runs.

func Loader

func Loader(spinner, status string) string

Loader composes the working indicator: spinner frame and status verb. spinner is the already-chosen animation frame (theme.SpinnerFrames picks that; Loader does not reimplement frame selection).

func Prefixed

func Prefixed(prefix, content string) string

Prefixed lays out a block as `prefix + first line`, with continuation lines indented to the prefix width. Shared by both presenters' Message for the roles whose layout doesn't vary (RoleAgent, RoleError, and inline's RoleUser/RoleAssistant) — full's RoleUser/RoleAssistant use GutterLines instead, since a gutter repeats down every line rather than heading the block once (see full's own doc for why).

func ShortID

func ShortID(id string) string

ShortID truncates an id to a compact display form (an 8-character prefix). Shared by tui (tool/job labels) and every Presenter (agent-tagged tool labels), so the two never drift apart.

func StyledLines

func StyledLines(style lipgloss.Style, content string) string

StyledLines applies style.Render to each line of content independently (rather than to the joined block), matching the original per-line styling the hand-rolled agent-message loop used to do.

func TruncateLine

func TruncateLine(s string, w int) string

TruncateLine caps a single line at w runes, ending with an ellipsis. A non-positive budget returns the bare ellipsis rather than an unclamped line.

func TruncateRunes

func TruncateRunes(word string, width int) string

TruncateRunes shortens word to at most width display columns, breaking on a rune boundary and appending an ellipsis when there is room for it.

func Wrap

func Wrap(text string, width int) string

Wrap wraps text to fit within the specified width, preserving existing newlines

Types

type Base

type Base struct {
	Prefix func(role Role) string
}

Base holds every Presenter method whose implementation genuinely does not vary between inline and full: ContentWidth, Reasoning, Dialog, Header, HeaderHeight, Footer and FooterHeight. Both presenters embed a Base and override only what actually differs — Caps, the role-prefix behaviour, Message, and Frame (stack vs overlay) — see each presenter's own doc for why those four resist sharing.

This is Task 17's extraction: a Phase 2 ruling deferred it on the prediction that Phase 3 would diverge the chrome substantially. It diverged exactly one function (contentPrefix's user/assistant arms) and added one (gutterLines) — everything below was still byte-identical between the two files.

Prefix is the one piece of the divergent chrome Base itself needs: the per-role prefix ContentWidth measures (inline's word label, full's one-column gutter). Message does NOT move here — the label-vs-gutter difference runs deeper than a single substitutable prefix string (inline also varies its prefix by the previous role and lays a block out with a first-line-only prefix, while full repeats its gutter down every line), so each presenter keeps its own Message rather than Base trying to parameterise all of that too. Go embedding does not give virtual dispatch — Base cannot call back into the embedding presenter's own contentPrefix by name — so the embedder passes its function in via this field instead (inline/full's New() sets it), rather than Base silently calling some default of its own and producing the wrong width for whichever surface didn't get looked up.

func (Base) ContentWidth

func (b Base) ContentWidth(role Role, w int) int

ContentWidth reports how many cells are left for a role's content once this surface's chrome (b.Prefix) is accounted for. The result is clamped to at least 1: a terminal narrower than the chrome must still give a renderer a legal width rather than zero or a negative one.

Panics if Prefix is nil — a Presenter that embeds Base without wiring it up (inline/full's New() both do) would otherwise fail with a bare nil-func dereference at the call site, naming neither the cause nor the culprit. Silently defaulting instead would render the wrong chrome for whichever surface forgot to set it, which is worse than a loud failure.

func (Base) Dialog

func (Base) Dialog(d Dialog, w int) string

Dialog renders a modal prompt. DialogAsk lays out the question (Title), then one row per option — a cursor mark on the highlighted row, a radio or checkbox per dialogAskMarker, the row text in theme.ApproveKey when highlighted and theme.Help otherwise (mirroring DialogApproval's Emphasis styling) — and Hint beneath, wrapped. An ask with no options (free-text only) degrades to placing Title alone. DialogResume (the /resume picker) shares this exact branch: buildResumeDialog (tui/resume.go) produces the same Title + single-select Options + Hint shape buildAskDialog does, so there is nothing for a Presenter to render differently. DialogApproval lays out Rows (the argument card, or — when RowsUnstructured — a single wrapped prose block), Hint (the captured reasoning, wrapped) and Options (the choice menu, one line per option styled per its Emphasis).

func (Base) Footer

func (Base) Footer(v ViewState, w int) string

Footer renders the new-output marker (when scrolled up with unread content below the fold), the help/badges line, the error line, and the job-status footer rows — everything that lives between the composer and the bottom of the screen.

func (Base) FooterHeight

func (b Base) FooterHeight(v ViewState, w int) int

FooterHeight reports how many terminal rows Footer occupies for this ViewState at this width — 1 for the bare help line, up to 7 once the new-output marker, an error line and the four job-status rows are all present. The shared core budgets the viewport against it, so an answer that disagrees with Footer by even one row makes the composed frame overflow the screen. Measuring the real output is the only way the two cannot drift.

func (Base) Header

func (Base) Header(v ViewState) string

Header renders the brand/badge line and the rule beneath it.

func (Base) HeaderHeight

func (b Base) HeaderHeight(v ViewState) int

HeaderHeight reports how many terminal rows Header occupies above the body — two today (the brand/cwd line and the hairline beneath it). Measured from the real string rather than stated as a constant, so a header redesign re-budgets the layout instead of silently pushing the frame past the terminal's last row. See BlockRows for why this counts newlines rather than using lipgloss.Height: Frame writes body straight onto the header.

func (Base) Reasoning

func (Base) Reasoning(v ViewState, w int) string

Reasoning renders the working indicator (spinner + status verb) and, when loading, the collapsible reasoning trace beneath it. Renders nothing when !v.Loading. The trace itself is capped through CollapsibleBox: v.Reasoning .Collapsed/MaxLines are populated by the model in viewState() from Model.reasoningCollapsed and theme.ReasoningMaxLines. Collapsed, the box shows the TRAILING lines of the trace (never the leading ones) — see CollapsibleBox's doc for why: a head-anchored box freezes on the trace's opening words and reads as a hang, while tailing doubles the box as its own progress indicator.

type Caps

type Caps struct {
	AltScreen bool
	Mouse     bool
	// OverlayDialogs is true for a surface whose Frame places v.Dialogs itself
	// (as a real overlay composed fresh every frame) rather than relying on
	// them having been baked into body as scrollback content. It is a
	// separate concern from AltScreen — "owns the screen" and "overlays
	// dialogs" are not the same thing, and a future alt-screen surface could
	// still choose to stack — so the shared core must not infer one from the
	// other: it uses OverlayDialogs to decide whether to also append Dialog
	// output into the scrollback it feeds the viewport, which would otherwise
	// render every pending dialog twice on a surface that overlays.
	OverlayDialogs bool
}

Caps declares what a Presenter's surface supports, so the shared core can adapt behaviour (e.g. mouse-driven scrolling) without a presenter needing to know about the other surface.

type CollapsibleBox

type CollapsibleBox struct {
	Lines     []string
	MaxLines  int
	Collapsed bool
}

CollapsibleBox caps a body to MaxLines. It backs the live model-reasoning trace (Phase 3 Task 10): collapsed, it shows the LAST MaxLines rather than the first. A head-anchored box would freeze on the trace's opening words and never move once the body outgrows MaxLines, which reads to the user as a hang — tailing makes the collapsed box double as its own progress indicator. Expanded, it shows every line.

func (CollapsibleBox) Hidden

func (b CollapsibleBox) Hidden() int

Hidden reports how many leading lines Visible omits.

func (CollapsibleBox) Visible

func (b CollapsibleBox) Visible() []string

Visible returns the lines this box currently shows: everything when expanded, or not truncated to fewer lines than MaxLines; otherwise the trailing MaxLines lines.

type Dialog

type Dialog struct {
	Kind             DialogKind
	Title            string
	Options          []DialogOption
	Selected         int
	Checked          []bool      // multi-select state; nil for single-select
	Rows             [][2]string // key/value detail rows (approval argument cards)
	RowsUnstructured bool        // true: Rows is a single prose row, not a key/value table
	Hint             string
}

Dialog is a modal prompt (tool approval, ask, resume) awaiting user input.

DialogAsk (Phase 3 Task 11) carries the question in Title and one DialogOption per choice in Options — Emphasis is unused for this kind, left false — with Selected the highlighted row (single-select) and Checked the per-row checked state (multi-select; nil for single-select, same convention DialogApproval never used until now). Hint is the line a Presenter renders beneath the rows: the free-text escape-hatch reminder (typing instead of picking always works — see tui/ask.go's buildAskDialog and tui/model.go's KeyEnter handling). Options is already windowed to what should be visible (render.SelectList.Window(), applied by the builder, since Dialog itself carries no MaxVisible) — a Presenter renders every row it's given. An ask with no options at all (free-text-only) arrives with Options empty; a Presenter falls back to placing Title alone, the same degrade any other Title-only kind gets for free.

DialogResume (Phase 3 Task 15, the /resume picker) reuses this exact same shape — Title, single-select Options, Hint — built by tui/resume.go's buildResumeDialog, so both presenters render it through their DialogAsk branch with no dedicated DialogResume rendering code at all.

DialogApproval uses the rest of the fields:

  • Rows holds the argument card: one [key, value] pair per structured argument (chat.ToolArgRows). RowsUnstructured, when true, means Rows instead holds exactly one entry whose second element is a raw prose block (the tool call formatted as text) for a tool chat.ToolArgRows doesn't recognize — a Presenter wraps and dims it instead of laying out a key/value table. This flag exists so that distinction is explicit in the type, rather than inferred from an empty Rows[0][0] key (which is not actually guaranteed unique: a tool whose JSON arguments contain a literal "" key would collide with that convention).
  • Hint is the tool call's captured reasoning (if any), rendered wrapped beneath the rows.
  • Options is the choice menu: 4 entries (once / always / this-turn / deny-edit, matching the on-screen approval prompt) in the normal case, or a single entry (the edit-mode hint) while editing. A Presenter should not assume only those two counts occur — render whatever list it's given — but may special-case exactly 4 to reproduce the classic approval layout (a blank gutter line before the menu).

type DialogKind

type DialogKind int

DialogKind identifies which modal dialog is being shown.

const (
	DialogApproval DialogKind = iota
	DialogAsk
	DialogResume
)

type DialogOption

type DialogOption struct {
	Text     string
	Emphasis bool
}

DialogOption is one line of a Dialog's choice menu. Emphasis marks it as an actionable key (styled as such); a false Emphasis is a dimmer, non-key hint line (e.g. the approval block's "[n] no · [e] edit" line, which sits among three actionable choices but isn't one itself).

type FooterRow

type FooterRow struct {
	Glyph string
	Text  string
	Kind  FooterRowKind
}

FooterRow is one line of the footer's job-status area (active sub-agent jobs, shell jobs, cron loops, the active goal). It carries data, not pixels: Glyph is the marker rune (e.g. theme.Loop), Text is the already- composed but UNSTYLED line, and Kind says which of the four rows this is so a Presenter can apply the right style (FooterJobs/FooterShell get the original theme.Meta + width-fill treatment; FooterLoops/FooterGoal get theme.Subtle, unfilled — see inline.Footer). The tui-side callers that build these (tui/agents.go, tui/shelljobs.go, tui/loops.go, tui/goal.go) must not depend on any Presenter or style types, only produce plain data, since a Presenter must never import their argument types (agentJob, *loop.Registry, wizmcp.ShellJobInfo).

type FooterRowKind

type FooterRowKind int

FooterRowKind identifies which footer row a FooterRow is, so a Presenter can map it to the right style. The four rows are not all styled alike (the original hand-rolled footer gave jobs/shell one treatment and loops/goal another), so this is domain data the tui-side builders supply — not something a Presenter can infer from Glyph/Text alone.

FooterKindUnset is deliberately the zero value: a FooterRow built without setting Kind (a forgotten case in a future fifth row) must degrade to a Presenter's plain default styling, not silently adopt FooterJobs's treatment just because it happens to be int 0.

const (
	FooterKindUnset FooterRowKind = iota
	FooterJobs
	FooterShell
	FooterLoops
	FooterGoal
)

type Message

type Message struct {
	Role    Role
	Content string
	// Label is the one-line heading of a RoleTool block: the tool call already
	// rendered as a human summary. It arrives formatted because turning a tool
	// name plus its raw JSON arguments into that summary is domain logic
	// (chat.FormatToolCall) — the same rule that keeps markdown rendering and
	// the ask block model-side. A Presenter places it and styles it; it never
	// imports chat to build it.
	Label   string
	AgentID string
	// HugNext is meaningful for RoleAgent only: true when the next raw message
	// continues this same agent's thread (a run of agent_tool/agent_result
	// lines rendered separately by the model, never through Message). A
	// Presenter must omit its own trailing separator in that case — the
	// thread run that follows hugs it instead. The model computes this by
	// looking ahead at the next raw message, which a Presenter never sees, so
	// it cannot be derived from prev/Role alone.
	HugNext bool
}

Message is a single rendered chat entry.

type Presenter

type Presenter interface {
	Caps() Caps
	Header(v ViewState) string
	HeaderHeight(v ViewState) int
	Message(m Message, prev Role, w int) string
	ContentWidth(role Role, w int) int
	Reasoning(v ViewState, w int) string
	Dialog(d Dialog, w int) string
	Footer(v ViewState, w int) string
	FooterHeight(v ViewState, w int) int
	Frame(v ViewState, header, body, composer, footer string, w, h int) string
}

Presenter renders a ViewState (and its parts) into strings for one surface — the inline fzf-style widget or the full-screen alt-screen mode. The shared core drives a Presenter; a Presenter holds no state of its own.

Reasoning takes the full ViewState (not just its Reasoning field) because the working indicator it renders — the spinner frame and status verb — lives on ViewState.Spinner/Status, the single source of truth for both (rather than duplicating them onto the narrower Reasoning type). A Presenter should render nothing when !v.Loading.

ContentWidth exists because some content is rendered before it reaches a Presenter: markdown is glamour output, and glamour is width-cached state the model owns, so the model must know at what width to render. That width is whatever this surface's chrome leaves, which only the Presenter knows. The model asks for the number rather than for the prefix string: a surface whose chrome is not a literal per-line prefix (a frame, a hanging gutter) can still answer a width, and the model never has to measure chrome it did not compose.

HeaderHeight and FooterHeight exist for the same reason in the other direction: the core budgets the viewport's height by subtracting the chrome that surrounds it, and neither piece is a constant. HeaderHeight was a hardcoded 2 in the core's layout budget for three phases — correct only for as long as both surfaces drew the same plain brand line plus hairline; a bordered full-screen header would have mis-budgeted silently, exactly as the composer used to before it was measured. It reports the number of rows the header occupies ABOVE the body (see BlockRows): Frame concatenates body straight onto header, so a header ending in "\n" occupies one row per newline.

FooterHeight is the same query at the bottom: the core budgets the viewport's height by subtracting the footer, and Footer emits anywhere from one row (the help line alone) to seven (new-output marker, error line, four job-status rows). A fixed guess makes an over-tall frame, which on the alt screen scrolls the header off the top. It must agree with Footer exactly, for the same ViewState and width.

Frame is the whole-screen composition point (Phase 3 Task 10a). Before it existed, the core drove Header/Footer around the viewport and Message/Reasoning/Dialog INTO the viewport's scrollback — two call sites that never met, and neither received a height. That made a full-screen layout impossible to express: a presenter that owns the whole screen (alt-screen `full`) has no way to place a dialog as a real overlay rather than transcript content that scrolls away with history, and no way to know how tall the screen even is.

Frame takes header, body, composer and footer already rendered — the core still calls Header/Message/Reasoning/Dialog/Footer to build them, exactly as before — because two of them are needed earlier than Frame can run: body's own content depends on the viewport's width and height, which the core must budget (from the footer's height) before it can lay out a single line of transcript, and footer is that same budget's input. Passing footer through as an already-rendered string (rather than Frame calling Footer(v, w) itself) is what lets the core measure it once per frame and reuse the string for the frame's own footer row, rather than rendering it twice (Task 10a's third carried defect). composer bundles whatever sits between the body and the footer this frame — the `/` completion popup, the queued-message block, the textarea, in whatever combination is present — since none of those has a Presenter method of its own (they are tui-side concerns: completion state, queue state, textarea state) and Frame does not need to distinguish them to place them.

header and footer are both pure functions of v (Header(v) and Footer(v, w)), so v carries a second source of truth for the same two pieces — this states which one wins. header MAY be discarded and re-derived from v: nothing depends on measuring it before Frame runs, so a presenter that draws it as part of a box border (rather than a plain top row) is free to call Header(v) itself instead of placing the given string. footer MUST NOT be re-derived — it is the exact string the layout budget upstream was measured against (via FooterHeight, before body was ever laid out), and calling Footer(v, w) again inside Frame reintroduces the double render this shape exists to prevent.

v is carried alongside the four strings (rather than Frame taking only them) for what it carries that isn't captured in any one piece: v.Dialogs, so a surface that owns the whole screen can place a dialog as a real overlay on top of body instead of it being baked into body as scrollback content. Phase 3 Task 11 (the ask_user dialog) is the first to exercise this: full.Frame now places v.Dialogs itself (docked above the composer — see its doc comment for why not centred), and Caps.OverlayDialogs tells the shared core to stop also baking them into body's scrollback for that surface, so they render exactly once. A later /resume picker follows the same shape.

A presenter that stacks (inline) concatenates the four pieces in the order they always rendered in, and relies on the core having already rendered v.Dialogs into body via its per-message loop. A presenter that owns the screen and declares Caps.OverlayDialogs (full) instead renders v.Dialogs itself inside Frame, since the core skips that append for it.

type Reasoning

type Reasoning struct {
	Text      string
	Collapsed bool
	MaxLines  int
}

Reasoning is the model's in-progress reasoning trace: the text beneath the working indicator (spinner + status), which lives on ViewState.Spinner and ViewState.Status — kept there only, so there is exactly one source of truth for them.

Collapsed and MaxLines are populated by the model's viewState() (Phase 3 Task 10, collapsible reasoning) from Model.reasoningCollapsed and theme.ReasoningMaxLines, and read by both presenters' Reasoning to build a render.CollapsibleBox.

type Role

type Role string

Role identifies the speaker or origin of a Message.

const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleAgent     Role = "agent"
	RoleTool      Role = "tool"
	RoleError     Role = "error"
	RoleNone      Role = ""
)

type SelectList

type SelectList struct {
	Items       []string
	Selected    int
	Checked     []bool // non-nil enables multi-select
	MaxVisible  int    // 0 means unlimited
	MultiSelect bool
}

SelectList is the shared option picker: ask_user (Phase 3 Task 11), /resume (Task 15), and eventually the Ctrl+O job list. It holds no bubbletea state — a presenter reads it and renders strings; the model mutates it in response to key events.

func (*SelectList) Answer

func (l *SelectList) Answer() string

Answer returns the selected item, or, for multi-select, every checked item joined by ", ". It returns "" for an empty list, an out-of-range selection, or a multi-select with nothing checked.

func (*SelectList) Move

func (l *SelectList) Move(delta int)

Move shifts Selected by delta, wrapping at both ends. It uses ((i % n) + n) % n rather than Go's %, which returns a negative result for a negative dividend, so a delta of -1 from index 0 lands on the last item instead of an invalid negative index. A no-op on an empty list.

func (*SelectList) Page

func (l *SelectList) Page(delta int)

Page moves the selection by one window's worth of items (MaxVisible), clamping at either end rather than wrapping the way Move does: one wrong arrow-key press is harmless and reversible, but a coarse page jump that teleported from the last item back to the top would read as a bug. A non-positive MaxVisible (unlimited: conceptually one page) falls back to the full list length, so paging in either direction jumps to that page's edge — Home/End behaviour. A no-op on an empty list.

func (*SelectList) Toggle

func (l *SelectList) Toggle()

Toggle flips the checked state of the current selection. It is a no-op unless MultiSelect is enabled (Checked non-nil), and safe on an empty list or an out-of-range selection.

func (*SelectList) Window

func (l *SelectList) Window() (start, end int)

Window returns the [start, end) slice of Items to display, clamped to MaxVisible (0 means unlimited: the whole list) and biased to keep Selected off the edges of the window where the list is long enough to allow it.

type ViewState

type ViewState struct {
	Width       int
	Cwd         string
	Brand       string
	AutoApprove bool
	Loading     bool
	Status      string
	Spinner     string
	Reasoning   Reasoning
	Dialogs     []Dialog
	Help        string
	Badges      string

	NewOutput bool
	Err       string
	Footers   []FooterRow
}

ViewState is the read-only projection of Model state a Presenter renders from. It carries no behaviour — presenters read it and produce strings.

The model builds one ViewState per frame (see tui/model.go's viewState method) with every field populated — Reasoning/Dialogs included, even though the inline Presenter's Header/Footer never read them — so nothing here is silently nil for a Presenter that composes a whole alt-screen frame from one ViewState rather than being driven block-by-block.

It carries no transcript projection: a []Message field lived here for two phases with a producer and no consumer (updateViewport builds its own render.Message values inline, from the raw transcript, because it also has to route sub-agent thread runs and pre-render markdown), and the two projections had already drifted apart. Height went the same way — Frame receives the frame's height as an argument.

Dialogs is a slice, not a single *Dialog, because more than one prompt can be pending at once: a background sub-agent's gated tool approval and a foreground ask_user question are independent and not mutually exclusive (cogito propagates the tool-call callback into spawned sub-agents, which run in the background — see chat/session.go). A Presenter renders each in order; ordinarily the slice holds zero or one entry.

Fields from NewOutput onward were added by Task 6, which is the first to actually build a ViewState (Task 5 declared the struct with no call sites). The original Task 5 contract had no way to reach the model's viewport or its job-registry state from a Presenter, both of which the pre-existing hand-rolled Footer rendering needs:

  • NewOutput: the presenter cannot see m.viewport, so the model resolves "is there unread content below the fold" (showingViewport && !m.viewport.AtBottom()) itself and passes the answer through.
  • Err: the plain (unstyled) text of the model's last error, or "" for none. The presenter applies the error glyph and style.
  • Footers: plain {Glyph, Text, Kind} data for the active-jobs/shell-jobs/ loops/goal footer rows (see FooterRow) — the presenter styles (per Kind) and joins whichever are present, in order.

Directories

Path Synopsis
Package full implements render.Presenter for nib's full-screen surface: the alt-screen mode with mouse reporting enabled.
Package full implements render.Presenter for nib's full-screen surface: the alt-screen mode with mouse reporting enabled.
Package inline implements render.Presenter for nib's default surface: the fzf-style inline widget that lives in the normal terminal scrollback (no alt screen, no mouse reporting).
Package inline implements render.Presenter for nib's default surface: the fzf-style inline widget that lives in the normal terminal scrollback (no alt screen, no mouse reporting).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL