Documentation
¶
Index ¶
- func BlockRows(s string) int
- func Loader(spinner, status string) string
- func Prefixed(prefix, content string) string
- func ShortID(id string) string
- func StyledLines(style lipgloss.Style, content string) string
- func TruncateLine(s string, w int) string
- func TruncateRunes(word string, width int) string
- func Wrap(text string, width int) string
- type Base
- func (b Base) ContentWidth(role Role, w int) int
- func (Base) Dialog(d Dialog, w int) string
- func (Base) Footer(v ViewState, w int) string
- func (b Base) FooterHeight(v ViewState, w int) int
- func (Base) Header(v ViewState) string
- func (b Base) HeaderHeight(v ViewState) int
- func (Base) Reasoning(v ViewState, w int) string
- type Caps
- type CollapsibleBox
- type Dialog
- type DialogKind
- type DialogOption
- type FooterRow
- type FooterRowKind
- type Message
- type Presenter
- type Reasoning
- type Role
- type SelectList
- type ViewState
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func BlockRows ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
TruncateLine caps a single line at w display cells, ending with an ellipsis. A non-positive budget returns the bare ellipsis rather than an unclamped line.
func TruncateRunes ¶
TruncateRunes shortens word to at most width display columns, breaking on a rune boundary and appending an ellipsis when there is room for it.
Types ¶
type Base ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) HeaderHeight ¶
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 ¶
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 ¶
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 ¶
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 {
}
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 ( )
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
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 ¶
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 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
}
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.
Source Files
¶
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). |