ninebox

package module
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

README

ninebox

A k9s-style shell for Bubble Tea applications: a category sidebar beside a resource pane, : commands, / filters, a stack of frames where esc is always go back, and one action table that feeds the legend, the footer and the help.

None of that is about any particular thing being browsed. It came out of gu, a terminal UI for GitLab and GitHub, where roughly two thirds of the UI turned out never to have heard of a merge request. This is that two thirds, with the merge requests taken out.

 Context   ~/src                                      enter     open                             _
                                                      /         filter                          | |
 User      brandon                                    1-9       jump to a category              |_|
 Entries   34                                         tab       switch pane           ⟳ every 5s · now
                                                      <:>       Command
╭─── categories ───╮ ╭──────────────────────── files(~/src)[34] ─────────────────────────╮
│₁ ~ Home          │ │NAME              SIZE   MODIFIED                                  │
│₂ / Root          │ │ninebox/          -      2m                                        │
│₃ . Working dir   │ │gu/               -      1h                                        │
│                  │ │README.md         6K     3d                                        │
╰──────────────────╯ ╰───────────────────────────────────────────────────────────────────╯
 files

The idea

You supply an App: your banner facts, your sidebar, and your first frame. The shell supplies everything else — the frame stack, the list, the prompt bar, the key legend, the help overlay, the refresh timer, mouse handling.

type App interface {
	Attach(*ninebox.Model)      // the shell you run in, handed over once
	Facts() []ninebox.Fact      // the banner's left-hand block
	Categories() []ninebox.Category
	Start() *ninebox.Frame      // the first screen
}

That is the whole required surface. Everything else is an optional interface, checked with a type assertion, so an app pays only for what it wants:

Interface For
Starter work to run at startup, beside the first load
Keyer keys the list screen does not already claim
Messenger your own asynchronous messages
Describer what d shows for a row, and y for its raw form
Linker what o opens in a browser
Commander words the : bar accepts everywhere
Actioner your keys, in the legend and the help
Helper extra sections in the help overlay

Frames and rows

A Frame is one screen of rows: a title, a loader, and what it produced. Frames form a stack, which is what makes esc mean one thing everywhere.

func repoFrame(path string) *ninebox.Frame {
	return ninebox.Listing(
		"files", path,
		[]string{"NAME", "SIZE", "MODIFIED"},
		func(ctx context.Context) ([]entry, error) { return readDir(ctx, path) },
		func(e entry) ninebox.Row {
			row := ninebox.Row{Cols: []string{e.Name, e.Size, e.Age}, Payload: e}
			if e.IsDir {
				row.Enter = func() *ninebox.Frame { return repoFrame(e.Path) }
			}
			return row
		},
	)
}

Row.Payload carries your object, untouched. The shell moves a cursor over rows; what a row is stays yours.

Loading in instalments

A screen whose columns arrive at different speeds should not wait for the slowest one. Frame.Stream supersedes Load and may emit as many times as it likes — each emit replaces what the list shows, and the list starts drawing after the first, not the last.

&ninebox.Frame{
	Resource: "mergerequests", Headers: headers,
	Stream: func(ctx context.Context, emit func([]ninebox.Row)) error {
		mrs, err := listMergeRequests(ctx)
		if err != nil {
			return err
		}
		emit(rowsFor(mrs, nil))       // on screen now, CI column pending

		ci := loadPipelines(ctx, mrs) // the slow half
		emit(rowsFor(mrs, ci))        // same rows, column filled in
		return nil
	},
}

Set Row.ID when you do this. The cursor then follows the row rather than the index, so an instalment that re-orders the list or fills a column in leaves the selection where the reader put it. A stream that fails after emitting keeps what it emitted and reports the failure over it.

Frame.Seed is the other half: rows to draw before the load runs — a cache read, called on the UI goroutine, so it must not block. A seeded frame is on the display immediately and revalidates underneath, with the banner's age line saying how old what you are reading is.

Screens

Anything that is not a list — a detail page, a board, a diff — is a Screen, pushed over the list. The shell keeps drawing the banner, the sidebar and the footer around it, and esc pops it.

type Screen interface {
	Title() string
	View(width, height int) string
	Key(msg tea.KeyMsg) (tea.Cmd, bool)
	Actions() []ninebox.Action
}

With optional Commands, Hints, Refresh, Mouse, Resize, and Overlay for a screen that floats over what opened it. A screen that implements Refresher is one the refresh timer will re-fetch; one that does not is treated as a snapshot and left alone, which is exactly right for a log or a help page.

Auto-refresh

Off by default, WithAutoRefresh(d) to switch it on. It stands down over prompts, over screens that cannot re-fetch themselves, and while a fetch is already in flight — an unattended refresh must never move what someone is reading or typing. The banner reports the cadence and how old the data is, and turns red when refreshes start failing, because a screen that has quietly stopped updating looks exactly like one where nothing is happening.

Keys the shell owns

↑↓/jk move · g/G first/last · enter open · esc back · tab switch pane · 1–9 category · / filter · : command · ? help · d describe · y raw · o browser · r reload · ctrl+r refresh · ctrl+c quit.

Everything else is yours.

Dialogs

Things every browsing application ends up writing, so the shell writes them once:

m.Compose(ninebox.Compose{        // a paragraph, with a preview and $EDITOR
    Title:  "comment on !42",
    About:  quotedLines,
    Submit: func(body string) tea.Cmd { return post(body) },
})

m.Pick(ninebox.Pick{              // a filterable list, for choosing one of many
    Title:   "milestone",
    Choices: milestones,          // every option on offer, including any "none"
    Current: ticket.Milestone,
    Choose:  func(c ninebox.Choice) tea.Cmd { return setMilestone(c.Value) },
})

m.Read("a comment", render)       // one thing in full, over what is showing
m.Edit("patch.go", body, back)    // hand the buffer to $EDITOR and take it back

Compose claims every key it is offered, including the shell's own: a colon typed into a paragraph is a colon. Read claims none of them, because a reader is not an editor. Pick sits between the two: typing filters the list, but up/ctrl+p and down/ctrl+n move the cursor rather than j/k, which are just letters landing in the filter like anything else typed. The caller supplies every choice, including any clear-the-field option — the picker has no built-in "none" of its own.

Packages

Package Holds
ninebox the shell: model, frames, screens, chrome, prompt, dialogs, runtime
ninebox/theme the palette, the styles drawn from it, and the box and card frames
ninebox/text width-aware text: measure, pad, clip, strip, relative ages
ninebox/markdown markdown as styled terminal text, cached per width
ninebox/board a kanban screen: cards in lanes, grouped by an axis you name

The board

b := board.New(m, board.Options{
    Scope: "acme/widgets",
    Axes: []board.Axis{{
        Name:  "status",
        Lanes: func(c board.Card) []string { return []string{ticket(c).Status} },
        Order: board.Order("To do", "In progress", "Done"),
        Move:  func(c board.Card, from, to string) tea.Cmd { return setStatus(c, to) },
    }},
    Load: fetch,
    Open: openTicket,
})

An Axis says which lanes a card belongs in — several, for a card with several labels — how the lanes sort, and what moving a card between them writes back. An axis with no Move is view-only and the board says so rather than appearing to do nothing. The cursor keeps the card across a reload or a regrouping, not its position.

A Result can also name Lanes, the full set of columns to draw whether or not a card has landed in one yet — the statuses a project defines, not just the ones in use:

return board.Result{
    Cards: cards,
    Lanes: []string{"To do", "In progress", "Done"}, // drawn even while empty
}, nil

Left empty, lanes come from the cards, as before. Named, the set is a floor rather than a filter: a lane a card carries is drawn whether or not it was named, because a board aggregating several projects can't know every value in advance. The board itself has no opinion about the catch-all lane — an app that wants an empty (none) column drawn includes board.Lane in its own Lanes.

Try it

go run ./examples/browse

A filesystem browser in about two hundred lines. It exists to keep the shell honest: everything ninebox does for gu it does here for directories, and nothing in the framework knows which of the two it is running.

Status

v0.2.0 — the shape is settled but the surface is not yet frozen. It has one and a half applications on it (gu, and the example); the API review that matters is the third.

Built with Bubble Tea and Lip Gloss.

Licence

GNU AGPL v3.

Worth reading before you build on it: the AGPL is copyleft, and it reaches across the network. An application that links this shell is a derived work, so distributing it — or letting people use it over a network — means offering that application's source under the AGPL too.

Documentation

Overview

Package ninebox is a k9s-style shell for Bubble Tea terminal applications: a category sidebar beside a resource pane, `:` commands, `/` filters, a stack of frames where esc is always "go back", and one action table that feeds the legend, the footer and the help.

None of that is about any particular thing being browsed. An application supplies an App — its sidebar, its banner facts, its first frame — and the shell supplies everything around it. What the rows mean is the app's business; the shell only moves a cursor over them.

The smallest useful app is a Facts, a Categories and a Start away; see examples/browse for one that browses the filesystem.

Index

Constants

View Source
const (
	// ToastOK and ToastError are how long the status line holds a message. A
	// failure stays up twice as long: it is the one worth reading twice.
	ToastOK    = 4 * time.Second
	ToastError = 8 * time.Second
)

How long the status line holds a message, and how long a fetch may take.

Variables

This section is empty.

Functions

func ClearToast

func ClearToast() tea.Msg

ClearToast expires the status line now, for an app that has just replaced what the message was about.

func ClearToastAfter

func ClearToastAfter(d time.Duration) tea.Cmd

ClearToastAfter expires the status line after d, for an app that puts its own message there and wants it to behave like the shell's.

func FromBottom

func FromBottom(p *pagerScreen)

FromBottom opens the pager at the end of the text. A job log is read backwards from the failure, so landing at line one means scrolling past everything that worked.

func OpenBrowser

func OpenBrowser(url string) tea.Cmd

OpenBrowser hands a URL to the platform's opener.

It returns a command rather than opening inline because launching a browser can block on a cold start, and a terminal UI that freezes while the desktop thinks about it looks broken.

func StaticKeys added in v0.4.1

func StaticKeys(keys []DialogKey) func() []DialogKey

StaticKeys is Dialog.Keys for a dialog whose keys never change, which is most of them. It exists so that the common case does not have to write a closure to say "these, always".

Types

type Action

type Action struct{ Key, Does string }

Action is one key and what it does, in the words a newcomer needs.

One table drives the legend in the banner, the hint line in the footer and the help overlay, so a key that exists is a key the UI mentions and there is no second list to forget to update.

type ActionMsg

type ActionMsg struct {
	Text string
	Err  error
}

ActionMsg reports the outcome of a mutation. An app that writes anything returns one of these and gets the status line, and a reload, for free.

type Actioner

type Actioner interface{ ListActions() []Action }

Actioner adds the app's own keys to the list screen's legend and help. The shell's own navigation keys are added around them.

type Addressed

type Addressed interface{ For() Screen }

Addressed marks a message meant for one screen: the result of a load that screen started, the answer to something only it asked.

The shell delivers it to that screen if it is still open and drops it if it is not — so a fetch left in flight by a screen someone has closed cannot write into it, and a screen can start work without the application having to route the answer back.

type App

type App interface {
	// Attach hands the app the shell it runs in, once, before Init. It is how
	// an app reaches Push, Fail, Prompt and the rest without being handed the
	// model on every call.
	Attach(*Model)

	// Facts are the banner's left-hand block: the handful of things that decide
	// what an action would do. A Fact with no label is a blank line.
	Facts() []Fact

	// Categories is the sidebar menu, in display order. The digit shortcut for
	// each is its position in the list.
	Categories() []Category

	// Start is the first frame, built once at startup.
	Start() *Frame
}

App is what a ninebox application supplies: who it is, what its sidebar offers, and where it starts. Everything else — the frame stack, the list, the prompt bar, the legend, the help, the refresh timer — is the shell's.

The shell knows nothing about what the app browses. It moves a cursor over rows, and the rows carry whatever payload the app put in them.

An app grows by implementing the optional interfaces below: they are checked with a type assertion, so an app pays only for the ones it wants.

type AutoRefreshMsg

type AutoRefreshMsg struct{}

AutoRefreshMsg is the timer asking for the open screen to be re-fetched.

It is exported so an app — or a test — can ask for the same unattended refresh the timer performs, which reports a failure in the banner rather than out loud.

type Category

type Category struct {
	Icon  string // an icon name, resolved through WithIcons
	Label string
	Open  func() tea.Cmd
}

Category is one entry in the sidebar. Selecting it replaces what the main pane shows, the way switching resource type does in k9s.

Open returns a command rather than a frame so a category can open something that is not a list — a board, say — and so it can refuse, with a reason.

type Choice added in v0.4.0

type Choice struct {
	Label string
	Value string
}

Choice is one option: what it reads as, and what it means.

type Command

type Command struct {
	Name string
	Run  func() tea.Cmd
}

Command is one action the `:` bar can run by name. The command bar is meant to be the primary way to drive a ninebox app, with the keys as an accelerator, so every action a screen has a key for should also have a name here.

type CommandHandler

type CommandHandler interface {
	Command(line string) (tea.Cmd, bool)
}

CommandHandler answers a `:` line that none of the registered names matched, for a command that takes an argument: `:ctx work` is one word and one value, and a table of names cannot say that. It is asked last, before the bar reports the line as unknown.

type Commander

type Commander interface{ Commands() []Command }

Commander adds words to the `:` bar that work from every screen — an app's navigation vocabulary.

type Compose

type Compose struct {
	// Title names the dialog.
	Title string
	// About is what the text concerns, shown above the editor: the lines being
	// commented on, the row being renamed. Empty for a bare question.
	About string
	// Body seeds the editor.
	Body string
	// Placeholder is the grey prompt in an empty editor.
	Placeholder string
	// AllowBlank marks a dialog where an empty body is an answer in its own
	// right rather than a way to back out.
	AllowBlank bool
	// Preview renders the draft as it will finally appear. Without one the body
	// is treated as markdown, which is what it nearly always is.
	Preview func(body string, width int) string
	// Submit receives the finished text. It is not called when the dialog is
	// abandoned.
	Submit func(body string) tea.Cmd
}

Compose is a request for a body of text: what it is for, what it is about, and what to do with the answer.

Everything an application asks a person to write goes through it — a comment, a description, a reason — so markdown, the preview and the editor handoff are written once and behave the same everywhere.

type Describer

type Describer interface {
	Describe(payload any, raw bool) (string, bool)
}

Describer renders the selected row for the `d` key, and its raw form for `y`. Returning false means the row has nothing to describe, and the shell says so rather than opening an empty pane.

type Dialog added in v0.4.1

type Dialog struct {
	Title string
	// Kind names what sort of dialog this is, for Showing: "compose", "reader",
	// "review". It is how an application tells which of its own dialogs is up
	// without the shell exposing a type per dialog.
	Kind string
	// Above is drawn over the body behind a rule: the lines being commented on,
	// the row being renamed. Empty for a bare question.
	Above string
	// Editor, when set, is the body: the dialog draws it, hands it every key it
	// does not claim itself, and can be asked what it holds. A dialog whose body
	// is something else — a reader's viewport — leaves this nil and sets Body and
	// OnKey instead.
	Editor *Editor
	// Body renders the content to the dialog's inner width. Ignored when Editor
	// is set.
	Body func(width int) string
	// OnKey receives the keys the dialog itself does not claim: the reader's
	// scrolling. Ignored when Editor is set — it types instead. Without either,
	// an unclaimed key does nothing rather than escaping to the shell underneath.
	OnKey func(tea.KeyMsg) tea.Cmd
	// Mouse handles the wheel over the dialog, for a body that scrolls.
	Mouse func(tea.MouseMsg) tea.Cmd
	// Typing marks a dialog that collects what is typed into it — a composer's
	// paragraph, a picker's filter. Such a dialog claims every key it is offered,
	// including the shell's own: a colon typed into a filter is a colon, not the
	// command bar. A dialog that collects nothing leaves `:` and `?` to the shell,
	// because opening the command bar over something you are only reading is
	// reasonable.
	Typing bool
	// Keys is asked on every render rather than held as a list, because what a
	// dialog can do depends on where it is: the composer's preview key reads
	// "preview" while writing and "edit" while previewing, and a verdict a
	// provider will refuse is better not offered than offered and rejected.
	Keys func() []DialogKey
	// Size is the width band. A reader is legitimately wider than a composer, and
	// it is the only thing the two ever differed on for a reason.
	Size Size
}

Dialog is the chrome every overlay shares: a titled box floated over what opened it, as wide as the shell allows and as tall as what it holds.

A dialog declares its keys once, in Keys. The footer, the legend and the help overlay are all rendered from that one list, so a key cannot be added to a dialog without appearing everywhere the dialog says what it can do. The composer and the reader each used to keep three copies of their own keys, and the reader's footer had already fallen behind the keys it answered.

type DialogKey added in v0.4.1

type DialogKey struct {
	Key, Short, Does string
	Run              func() tea.Cmd
	// Hidden keeps a synonym working without advertising it: q for esc.
	Hidden bool
}

DialogKey is one key a dialog answers: how it is spelled, how the footer abbreviates it, what it does, and what it runs.

A nil Run means the body handles the key — scrolling a reader, typing into an editor — so it can still be advertised without the dialog claiming it.

type Editor added in v0.4.1

type Editor struct {
	// contains filtered or unexported fields
}

Editor is a body of text being written, usable as any Dialog's Body.

It exists apart from the composer because a dialog can have more than one way to finish — a review published as an approval rather than a remark — and every one of those needs the same editor, the same preview, the same $EDITOR handoff and the same rule about what counts as nothing written.

func NewEditor added in v0.4.1

func NewEditor(o EditorOpts) *Editor

NewEditor builds one, focused and ready to type into.

func (*Editor) Blank added in v0.4.1

func (e *Editor) Blank() bool

Blank reports that there is nothing here worth submitting — which an editor that treats blank as an answer never is.

Every submit path asks this rather than testing the value itself, so a dialog with three ways to finish cannot enforce the rule on only one of them.

func (*Editor) EditExternally added in v0.4.1

func (e *Editor) EditExternally(m *Model) tea.Cmd

EditExternally hands the draft to $EDITOR and takes back what comes out.

It needs the shell because the handoff suspends the whole program, which is not an editor's business to arrange.

func (*Editor) Previewing added in v0.4.1

func (e *Editor) Previewing() bool

Previewing reports which of the two is on screen, so a dialog can name its own toggle key accordingly.

func (*Editor) TogglePreview added in v0.4.1

func (e *Editor) TogglePreview() tea.Cmd

TogglePreview swaps between writing and seeing it rendered.

func (*Editor) Update added in v0.4.1

func (e *Editor) Update(msg tea.KeyMsg) tea.Cmd

Update gives a keypress to the editor.

While the preview is up the keys are dropped rather than typed into a buffer that is not on screen.

func (*Editor) Value added in v0.4.1

func (e *Editor) Value() string

Value is what has been written, without the trailing newlines a textarea collects on the way.

func (*Editor) View added in v0.4.1

func (e *Editor) View(width int) string

View draws whichever of the two is showing, at the width the dialog turned out to be.

type EditorOpts added in v0.4.1

type EditorOpts struct {
	// Body seeds the editor.
	Body string
	// Placeholder is the grey prompt in an empty editor.
	Placeholder string
	// AllowBlank marks an editor where an empty body is an answer in its own
	// right rather than a way to back out.
	AllowBlank bool
	// Preview renders the draft as it will finally appear. Without one the body
	// is treated as markdown, which is what it nearly always is.
	Preview func(body string, width int) string
}

EditorOpts describes an editor before it exists.

type Fact

type Fact struct {
	Label string
	Value string
}

Fact is one banner line: a label and an already-styled value. A Fact with an empty Label renders as a blank line, which is how a banner separates "where am I" from "who am I" — four pairs stacked with no break read as one grey block and are scanned as none of them.

type Frame

type Frame struct {
	// Resource and Scope form the border title, k9s-style:
	// "mergerequests(atomic-blend/backend/auth)[3]".
	Resource string
	Scope    string
	Headers  []string
	Load     func(ctx context.Context) ([]Row, error)

	// Stream, when set, supersedes Load: it may call emit any number of times,
	// and each call replaces what the list shows. It is how a screen whose
	// columns arrive at different speeds draws the cheap ones first — a list of
	// merge requests need not wait on the CI column to appear at all.
	//
	// emit is safe to call from any goroutine and never blocks for long: a
	// caller that has navigated away is abandoned rather than waited for.
	Stream func(ctx context.Context, emit func([]Row)) error

	// Seed supplies rows to draw immediately, before the load runs — a cache
	// read, not a fetch. It is called on the UI goroutine and must not block:
	// anything that could touch the network belongs in Load or Stream.
	//
	// Returning nothing is normal, and means the frame opens the usual way.
	Seed func() []Row

	// Meta is the app's own note about this frame — the project it belongs to,
	// what a picker will open once something is picked. The shell carries it and
	// never reads it.
	Meta any
	// contains filtered or unexported fields
}

Frame is one screen of rows: a title, a loader, and what it produced. Frames form a stack, so esc is always "go back".

func Listing

func Listing[T any](
	resource, scope string,
	headers []string,
	fetch func(context.Context) ([]T, error),
	toRow func(T) Row,
) *Frame

Listing builds a frame backed by a single fetch, converting each result into a row. Every remote-backed screen in a ninebox app is one of these.

func Menu(resource, scope string, entries ...Row) *Frame

Menu builds a frame of fixed entries, each opening another frame.

func (*Frame) ClampCursor

func (f *Frame) ClampCursor()

ClampCursor keeps the cursor inside the visible rows, after a load or a filter has changed how many there are.

func (*Frame) Current

func (f *Frame) Current() (Row, bool)

Current returns the selected row, if any.

func (*Frame) Cursor

func (f *Frame) Cursor() int

Cursor is the index of the selected row among the visible ones.

func (*Frame) Filter

func (f *Frame) Filter() string

Filter is the text rows are being matched against, empty when there is none.

func (*Frame) Loaded

func (f *Frame) Loaded() bool

Loaded reports whether the frame has ever finished a load. Until it has, the list says "loading…" rather than "nothing here" — an empty screen and an unfetched one are not the same answer.

func (*Frame) Move

func (f *Frame) Move(delta int)

Move shifts the cursor by delta, stopping at the ends.

func (*Frame) Rows

func (f *Frame) Rows() []Row

Rows is everything the last load produced, filter or no filter.

func (*Frame) SetCursor

func (f *Frame) SetCursor(i int)

SetCursor moves the cursor to i, clamped to the visible rows.

func (*Frame) SetFilter

func (f *Frame) SetFilter(s string)

SetFilter applies a filter and returns to the top of the list.

func (*Frame) SetRows

func (f *Frame) SetRows(rows []Row)

SetRows installs a load's result and drops the caches that described the old one.

The cursor is kept on the row it was on rather than at the index it was at, where the rows say who they are. A streaming load replaces the list each time it emits, and a selection that jumped every time a column filled in would make the list unusable exactly while it was most alive.

func (*Frame) Visible

func (f *Frame) Visible() []Row

Visible applies the current filter.

func (*Frame) Widths

func (f *Frame) Widths(total int) []int

Widths is the column layout for a pane of the given width, computed once per change rather than once per redraw.

func (*Frame) Window

func (f *Frame) Window(height int) (start, end int)

Window returns the half-open range of rows visible in a pane of the given inner height, scrolled to keep the cursor on screen. One row of the pane is spent on the column header.

type HelpSection

type HelpSection struct {
	Title string
	Keys  []Action
}

HelpSection is one titled block of the help overlay.

type Helper

type Helper interface{ Help() []HelpSection }

Helper adds sections to the help overlay, for anything the action tables do not already cover.

type Hint

type Hint struct{ Key, Does string }

Hint is one entry in the footer's compact key line: the key, and a word.

type Input added in v0.6.0

type Input struct {
	// contains filtered or unexported fields
}

Input is a labelled line of text, usable as the body of anything: a form's field, a wizard's step, a dialog of someone's own.

It exists so that a screen wanting one field does not have to reach for a whole form, and so that every one of them lines up the same way.

func NewInput added in v0.6.0

func NewInput(o InputOpts) *Input

NewInput builds one, focused and ready to type into.

func (*Input) Blur added in v0.6.0

func (i *Input) Blur()

func (*Input) Focus added in v0.6.0

func (i *Input) Focus() tea.Cmd

func (*Input) SetValue added in v0.6.0

func (i *Input) SetValue(v string)

SetValue replaces what it holds.

func (*Input) Update added in v0.6.0

func (i *Input) Update(msg tea.KeyMsg) tea.Cmd

Update gives it a keypress.

func (*Input) Value added in v0.6.0

func (i *Input) Value() string

Value is what has been typed, without the spaces around it.

func (*Input) View added in v0.6.0

func (i *Input) View(width int) string

View draws the label and the field at the given width.

type InputOpts added in v0.6.0

type InputOpts struct {
	// Label sits in the gutter to the left of the value.
	Label string
	// Value seeds it.
	Value string
	// Placeholder is the grey prompt in an empty field.
	Placeholder string
}

InputOpts describes a one-line field before it exists.

type Keyer

type Keyer interface {
	Key(key string) (tea.Cmd, bool)
}

Keyer answers a key the list screen did not handle. Returning false leaves the key unhandled, which is how an app adds keys without inheriting the shell's.

type Landing

type Landing interface{ LoadOutcome() error }

Landing marks an app message as the outcome of a load, so the banner's freshness line settles on it too.

The shell settles on its own row loads without being told. An app that fetches anything else — a page of comments, a board, a graph — implements this on the message that delivers it, and the banner then answers "how old is what I am looking at" rather than "when did a list last load".

type Linker

type Linker interface{ WebURL(payload any) string }

Linker gives a row its browser URL, for `o`.

type Messenger

type Messenger interface {
	Message(msg tea.Msg) (tea.Cmd, bool)
}

Messenger answers the app's own asynchronous messages. Returning false lets the shell go on to consider the message itself.

type Model

type Model struct {
	// contains filtered or unexported fields
}

Model is the root Bubble Tea model: the shell, and the app it is running.

func New

func New(app App, opts ...Option) *Model

New builds the shell around an app.

func (*Model) Actions

func (m *Model) Actions() []Action

Actions is everything the screen on show can do, in the order a newcomer needs it. The legend shows the top of it and the help overlay shows all of it, so a key that exists is a key the UI mentions.

func (*Model) Activate

func (m *Model) Activate() tea.Cmd

Activate opens the selected row: running its action, else drilling in, else describing it.

func (*Model) App

func (m *Model) App() App

App returns the running app, for the rare screen that has only the shell.

func (*Model) Ask

func (m *Model) Ask(label, initial string, f func(string) tea.Cmd) tea.Cmd

Ask puts a question in the prompt bar and runs f with the answer. A blank answer cancels: an empty comment is not worth posting.

func (*Model) AskAllowingBlank

func (m *Model) AskAllowingBlank(label, initial string, f func(string) tea.Cmd) tea.Cmd

AskAllowingBlank is Ask for a question where an empty answer means something — "blank to finish", "blank for none" — rather than a way to back out.

func (*Model) BodyHeight

func (m *Model) BodyHeight() int

BodyHeight is the inner height shared by both panes. The prompt bar, when it is up, takes three of those rows.

func (*Model) BodyWidth

func (m *Model) BodyWidth() int

BodyWidth is the inner width of the main pane: the terminal less the sidebar (inner plus two borders), the gap, and this pane's own two borders — or the whole terminal when there is no room for a sidebar.

func (*Model) CategoryCursor

func (m *Model) CategoryCursor() int

CategoryCursor is the highlighted category, which is not necessarily the one showing: highlighting is not opening.

func (*Model) CloseScreens

func (m *Model) CloseScreens()

CloseScreens returns to the list, closing every open screen. Navigating is what does this: a command typed on a detail page has to actually take you somewhere.

func (*Model) CommandBar

func (m *Model) CommandBar() tea.Cmd

CommandBar opens the `:` bar.

func (*Model) Compose

func (m *Model) Compose(c Compose) tea.Cmd

Compose opens the dialog over whatever is showing.

It is a Dialog with one way to finish. A caller that needs more than one — a review published as a comment, an approval or a change request — builds its own Dialog around an Editor rather than growing a second submit key here.

func (*Model) ComposeBody

func (m *Model) ComposeBody() (string, bool)

ComposeBody is what the open dialog currently holds, and whether one is open.

func (*Model) Confirm

func (m *Model) Confirm(label, expect string, f func() tea.Cmd) tea.Cmd

Confirm guards something destructive behind typing an exact string — the resource's own identifier, so the confirmation cannot be muscle-memory. The label should say what to type.

func (*Model) Describe

func (m *Model) Describe(payload any) tea.Cmd

Describe opens the detail pager on one of the app's own objects, for a screen that has something selected which is not a row in a list.

func (*Model) Dialog added in v0.4.1

func (m *Model) Dialog(d Dialog) tea.Cmd

Dialog opens one over whatever is showing.

func (*Model) Edit

func (m *Model) Edit(name, body string, back func(string)) tea.Cmd

Edit suspends the UI, runs $EDITOR on a buffer, and gives the result back.

The name matters: the temporary file is named after what is being edited, so the editor picks the right syntax for it — Go for a patch, markdown for a comment.

Only one edit can be in flight, because the terminal is handed over for the duration; a second call while an editor is open replaces where the result goes, which is the only sane reading of asking twice.

func (*Model) Fail

func (m *Model) Fail(text string) tea.Cmd

Fail reports an error in the status line and schedules its expiry.

func (*Model) Fetch

func (m *Model) Fetch(work func(ctx context.Context) tea.Msg) tea.Cmd

Fetch runs work against a bounded context, off the UI goroutine. Every load an app starts should go through it, or a provider that never answers is a UI that never comes back.

func (*Model) FillPick added in v0.6.0

func (m *Model) FillPick(choices []Choice) tea.Cmd

FillPick puts the choices into a picker opened before they existed.

A picker the reader has already left is not filled: the answer arrived for a question nobody is asking any more.

func (*Model) Filter

func (m *Model) Filter() tea.Cmd

Filter opens the `/` bar, seeded with the filter already on the list.

func (*Model) FocusSidebar added in v0.7.0

func (m *Model) FocusSidebar()

FocusSidebar hands the arrow keys to the category pane.

A screen that covers the list needs this: the shell's own sidebar keys are only reachable when nothing is open, so a full-screen view would otherwise be a place the category pane could not be reached from at all.

func (*Model) Frames

func (m *Model) Frames() []*Frame

Frames is the whole stack, oldest first — the breadcrumb, and where an app looks to answer "what am I inside of".

func (*Model) HelpText

func (m *Model) HelpText() string

HelpText is the full key reference for what is on screen, rendered. It is the same text the `?` overlay shows — an app that wants to print its keys rather than display them asks for it here.

With the overlay already open it answers with what that overlay is showing, not with a reference to the overlay itself: help is always about the screen it was opened over.

func (*Model) Icon

func (m *Model) Icon(name string) string

Icon resolves a category icon name through the active set.

func (*Model) InPane

func (m *Model) InPane(x, y int) bool

InPane reports whether a point is inside the main pane's content area.

func (*Model) Init

func (m *Model) Init() tea.Cmd

Init kicks off the first frame's load, the app's own startup work, and the auto-refresh timer when one is configured.

func (*Model) Loading

func (m *Model) Loading() bool

Loading reports whether a fetch is in flight.

func (*Model) MoveCategory

func (m *Model) MoveCategory(delta int)

MoveCategory shifts the sidebar cursor without opening anything.

func (*Model) Mutate

func (m *Model) Mutate(done string, work func(ctx context.Context) error) tea.Cmd

Mutate runs a write and turns its result into a status-line message. A successful one reloads what is on screen, because it has just gone stale.

func (*Model) NewPicker added in v0.6.0

func (m *Model) NewPicker(p Pick) *Picker

NewPicker builds one. Unlike Pick it opens nothing: the caller draws it.

func (*Model) OnList

func (m *Model) OnList() bool

OnList reports whether the list is what is on screen.

func (*Model) OpenHelp

func (m *Model) OpenHelp() tea.Cmd

OpenHelp shows the key reference for the screen it was opened from, or closes it if it is already open.

It goes through the pager because the reference is as long as the screen it describes — listing every action was the point — and a help screen that silently cuts off its last lines is worse than no help at all.

func (*Model) Pager

func (m *Model) Pager(title, body string, opts ...PagerOption) tea.Cmd

Pager opens a reading screen over whatever is showing: a log, a rendered document, anything already fetched that wants scrolling rather than a table.

func (*Model) PaneFocused

func (m *Model) PaneFocused() bool

PaneFocused reports whether the arrow keys are driving the main pane rather than the sidebar. A screen that draws its own selection asks this to decide how loudly to mark it.

func (*Model) PaneOrigin

func (m *Model) PaneOrigin() (x, y int)

PaneOrigin is where the main pane's content starts on screen, in terminal coordinates. A screen that hit-tests its own body — a board with clickable cards, a diff with foldable files — subtracts it from a mouse event to get a position inside what it drew.

func (*Model) Pick added in v0.4.0

func (m *Model) Pick(p Pick) tea.Cmd

Pick opens the picker over whatever is showing.

func (*Model) PickValue added in v0.4.0

func (m *Model) PickValue() (string, bool)

PickValue is what the open picker's cursor is on, and whether one is open. It exists for the same reason ComposeBody does: a test, and an application that needs to know what is being looked at.

func (*Model) Pop

func (m *Model) Pop() tea.Cmd

Pop returns to the previous frame, loading it if it was never loaded.

func (*Model) PopScreen

func (m *Model) PopScreen() tea.Cmd

PopScreen closes the top screen, returning to the one below it, or to the list. It is what esc does, and what a screen's own "close" should call.

func (*Model) PromptKind

func (m *Model) PromptKind() string

PromptKind names what the bar is collecting — "filter", "command", "confirm" or "input" — and is empty when the bar is down.

func (*Model) Prompting

func (m *Model) Prompting() bool

Prompting reports whether the prompt bar is on screen. Nothing else should be reading the keyboard while it is.

func (*Model) Push

func (m *Model) Push(f *Frame) tea.Cmd

Push opens a new frame and loads it.

func (*Model) PushScreen

func (m *Model) PushScreen(s Screen) tea.Cmd

PushScreen opens a screen over whatever is showing.

func (*Model) Question added in v0.5.0

func (m *Model) Question(q Question) tea.Cmd

Question opens one over whatever is showing.

func (*Model) QuietRefreshFailure

func (m *Model) QuietRefreshFailure() bool

QuietRefreshFailure reports whether the failure that just landed came from the timer, and consumes the flag.

An automatic failure is shown by the banner rather than the status line: at a two-second interval a toast per failure is a strobe, and one nobody asked for at that. A failure someone pressed ctrl+r for still says so out loud. An app that reports its own load failures should ask this first.

func (*Model) Read

func (m *Model) Read(title string, render func(width int) string) tea.Cmd

Read opens one piece of text in full, over whatever is showing.

It is the counterpart to Pager: a pager is a screen and takes the pane, a reader is a dialog and takes only the room it needs. Something already on screen in summary — a comment folded to one line, a cell cut to its column — opens in a reader; a log opens in a pager.

The body is rendered to the width the dialog turns out to be, which is the shell's to decide; text that needs no wrapping ignores the argument.

func (*Model) RefreshNow

func (m *Model) RefreshNow() tea.Cmd

RefreshNow is ctrl+r: the same refresh the timer does, on demand, from any screen and whatever the configuration says.

It says so in the status line, because a refresh that fetched identical data is indistinguishable from a key that did nothing at all.

func (*Model) Reload

func (m *Model) Reload() tea.Cmd

Reload fetches the top frame's rows under a fresh generation.

A frame with a Stream draws in instalments: the loader runs on its own goroutine, and each emit lands as its own message, so a screen whose columns arrive at different speeds shows the cheap ones as soon as it has them. A frame with only a Load is that same machinery with exactly one instalment, which is why there is no second path through here.

func (*Model) ReplaceScreen

func (m *Model) ReplaceScreen(s Screen) tea.Cmd

ReplaceScreen swaps the top screen for another, for a screen that becomes a different one rather than opening a second.

func (*Model) Reset

func (m *Model) Reset(f *Frame) tea.Cmd

Reset replaces the whole stack with one frame and loads it. Choosing a category does this: it is a new place, not a step deeper into this one.

func (*Model) RunCommand

func (m *Model) RunCommand(line string) tea.Cmd

RunCommand runs a `:` line as if it had been typed, for an app that wants to reach its own vocabulary from a key, a flag or a startup argument.

func (*Model) Screen

func (m *Model) Screen() Screen

Screen is the screen on top, or nil when the list is what is on screen.

func (*Model) Screens

func (m *Model) Screens() []Screen

Screens returns the open screens, outermost first. It is empty when the list is what is on screen.

func (*Model) SelectCategory

func (m *Model) SelectCategory(i int) tea.Cmd

SelectCategory switches the main pane to a category, by position — which is also its digit shortcut.

func (*Model) Selected

func (m *Model) Selected() (Row, bool)

Selected is the row under the cursor, if there is one.

func (*Model) SetLoading

func (m *Model) SetLoading(v bool)

SetLoading lights the loading indicator for work the app started itself.

func (*Model) SetToast

func (m *Model) SetToast(text string, isErr bool)

SetToast puts a message in the status line without scheduling its expiry, for an app that wants to time its own — or to leave one up until something else replaces it.

func (*Model) Showing

func (m *Model) Showing() (kind, title string)

Showing names the shell's own screen on top and gives its title: "help", "pager", "reader" or "compose". Both are empty when what is on screen belongs to the application.

An app asks before acting on something the reader cannot see, or to tell a dialog it opened from one the shell did.

func (*Model) Size

func (m *Model) Size() (width, height int)

Size is the terminal's, for a screen that needs to know more than the pane it is drawn into.

func (*Model) Status

func (m *Model) Status() (text string, isErr bool)

Status is what the status line is currently saying, and whether it is a failure. It is empty when the line is showing the breadcrumb instead.

func (*Model) Toast

func (m *Model) Toast(text string) tea.Cmd

Toast puts a message in the status line and clears it after a while.

func (*Model) Top

func (m *Model) Top() *Frame

Top is the frame currently backing the list.

func (*Model) Update

func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update handles input and asynchronous results.

func (*Model) View

func (m *Model) View() string

View renders the whole screen: the banner, the category sidebar beside the current pane, and the footer.

func (*Model) Wizard added in v0.6.0

func (m *Model) Wizard(w *Wizard) tea.Cmd

Wizard opens one over whatever is showing.

type Mouser

type Mouser interface {
	Mouse(msg tea.MouseMsg) tea.Cmd
}

Mouser handles mouse events over the main pane. The shell handles the sidebar itself, wherever the pointer is.

type Option

type Option func(*Model)

Option customises the shell at construction.

func WithAutoRefresh

func WithAutoRefresh(d time.Duration) Option

WithAutoRefresh re-fetches the open screen every d. Zero switches it off, which is what a disabled setting and a nonsensical interval both resolve to.

func WithFetchTimeout

func WithFetchTimeout(d time.Duration) Option

WithFetchTimeout bounds how long a load may take before it is abandoned.

func WithIcons

func WithIcons(glyphs map[string]string) Option

WithIcons supplies the glyphs category icon names resolve to. An app that offers its users a choice of icon sets — a patched font, geometric shapes, plain ASCII — resolves the choice and passes the winning set here.

func WithLogo(lines []string) Option

WithLogo sets the block drawn in the banner's top-right corner, as k9s draws its own. Without one the corner is left to the refresh line.

func WithNotice

func WithNotice(notice string) Option

WithNotice puts a standing warning at the top of the help overlay: a session that is read-only, or connected to somewhere unusual. It is the first thing help says, because it is the reason half the keys below it are missing.

type Overlay

type Overlay interface {
	Screen
	// OverlayView draws the screen over the fully-rendered background.
	OverlayView(background string) string
}

Overlay is a screen that floats over whatever opened it — a composer, a card read in full — so the thing being acted on stays visible around the edges.

type PagerOption

type PagerOption func(*pagerScreen)

PagerOption adjusts how a pager opens.

type Pick added in v0.4.0

type Pick struct {
	// Title names the dialog.
	Title string
	// Choices are the options, in the order they should be offered. An empty
	// set is not worth a dialog and is refused.
	Choices []Choice
	// Current is the value the thing already holds. The cursor opens on it and
	// it is marked, so pressing enter straight away changes nothing.
	Current string
	// Choose receives the option picked. It is not called when the dialog is
	// abandoned.
	Choose func(Choice) tea.Cmd

	// Multi collects a set rather than one: space toggles, enter confirms
	// everything ticked. Building a six-item stack should cost one visit to
	// the picker rather than six.
	Multi bool
	// Already are values the caller holds and will not take again — the merge
	// requests a stack has, the labels an issue carries. They are shown as
	// already there and cannot be ticked.
	Already []string
	// Chosen receives the set, in the order the choices were declared rather
	// than the order they were ticked. Multi pickers call this instead of
	// Choose.
	Chosen func([]Choice) tea.Cmd

	// Does names what confirming actually does — "add", "depend on", "assign".
	// A picker that says "choose it" for everything makes the reader work out
	// what it is choosing between; the caller already knows. Empty reads as
	// "choose it", or "add" for a multi picker.
	Does string
	// Loading opens the picker before its choices exist, showing that they are
	// on the way. FillPick puts them in. A dialog that appears only once the
	// network answers is indistinguishable from a key that did nothing.
	Loading bool
}

Pick is a request to choose one of a set — a category, an owner, a status. Everything an application asks someone to choose goes through it, so filtering and the marking of what is already set behave the same everywhere.

type Picker added in v0.6.0

type Picker struct {
	// contains filtered or unexported fields
}

Picker is the choosing list as a widget rather than as a dialog: the same filtering, marking and multi-selection, drawn wherever the caller wants it.

Pick opens one in a dialog of its own, which is right when choosing is the whole errand. A wizard's step, where choosing is one of four things being done, holds the widget instead — the step is already the screen.

func (*Picker) Chosen added in v0.6.0

func (p *Picker) Chosen() []Choice

Chosen is everything ticked, in the order the choices were declared.

func (*Picker) Current added in v0.6.0

func (p *Picker) Current() (Choice, bool)

Current is the row under the cursor.

func (*Picker) Fill added in v0.6.0

func (p *Picker) Fill(choices []Choice)

Fill puts choices into a picker drawn before they arrived.

func (*Picker) Keys added in v0.6.0

func (p *Picker) Keys() []DialogKey

Keys are the picker's own, for a caller building a key list around them.

The confirming key is left out: in a widget there is nothing to confirm to — the caller decides what finishing means, and asks Chosen when it happens.

func (*Picker) Loading added in v0.6.0

func (p *Picker) Loading() bool

Loading reports choices still on their way.

func (*Picker) Update added in v0.6.0

func (p *Picker) Update(msg tea.KeyMsg) tea.Cmd

Update gives it a keypress it did not claim through Keys: the filter.

func (*Picker) View added in v0.6.0

func (p *Picker) View(width int) string

View draws it at the given width.

type Question added in v0.5.0

type Question struct {
	// Title names the dialog: "delete stack".
	Title string
	// Message says what will happen, to what, and whether it is reversible.
	Message string
	// Yes and No label the buttons. Empty reads as "yes" and "no"; a verb reads
	// better on the ones that do something specific — "delete", "archive".
	Yes, No string
	// Danger marks an irreversible answer, which colours the yes button the way
	// an error is coloured rather than the way a choice is.
	Danger bool
	// OnYes runs when the answer is yes, after the dialog has closed.
	OnYes func() tea.Cmd
}

Question asks something with two answers, one of which may be destructive.

It is the guard on anything that cannot be undone. The focus opens on No, so enter — the key most likely to be pressed out of habit — always means "no"; saying yes takes a key that means yes.

type Receiver

type Receiver interface{ Receive(msg tea.Msg) tea.Cmd }

Receiver is a screen that takes messages addressed to it.

type Refresher

type Refresher interface{ Refresh() tea.Cmd }

Refresher is a screen that can re-fetch itself. A screen that does not implement it is a snapshot — a help page, a log, a described row — and the refresh timer stands down over it rather than pulling the ground out from under someone reading.

type Resizer

type Resizer interface{ Resize(width, height int) }

Resizer is told when the terminal size changes, for a screen that renders to a width and caches the result.

type Row

type Row struct {
	Cols    []string
	Kind    string // the app's name for what this row is; the shell only compares it
	Payload any

	// ID identifies this row across loads. When it is set, the cursor follows
	// it: an instalment of a streaming load that re-orders the list, or fills a
	// column in, leaves the selection on the same row rather than at the same
	// index. Without it the cursor keeps its position, which is right for a
	// list that only ever arrives once.
	ID string

	// Enter returns the frame to push when this row is selected, or nil if the
	// row is a leaf.
	Enter func() *Frame
	// Act runs instead of Enter when the row does something other than push a
	// frame — picking a project, say, which continues into a chosen view.
	Act func() tea.Cmd
}

Row is one line in a list. Payload carries the app's own object, so the detail pane and the action keys can act on it without a second fetch.

func Entry

func Entry(label, description string, open func() *Frame) Row

Entry is one menu row.

type Screen

type Screen interface {
	// Title is the border caption, already styled.
	Title() string
	// View draws the body at the inner size of the main pane.
	View(width, height int) string
	// Key handles a keypress. The shell has already dealt with the keys that
	// work everywhere — ctrl+c, ctrl+r, `:` and `?` — and pops the screen on esc
	// unless the screen takes esc for itself by returning true.
	Key(msg tea.KeyMsg) (tea.Cmd, bool)
	// Actions is what this screen can do, in the order a newcomer needs it: the
	// legend shows the top of it, the help overlay shows all of it.
	Actions() []Action
}

Screen is a body the app draws itself, pushed over the list: a detail page, a board, a diff. The shell keeps drawing the banner, the sidebar and the footer around it, and esc pops it.

Screens form their own stack, above the frame stack. A screen opened from a screen goes back to the one it came from, which is the only thing esc has ever been allowed to mean.

type ScreenCommands

type ScreenCommands interface{ Commands() []Command }

ScreenCommands is what `:` accepts on this screen. They are tried before the app's global words, so `:merge` on a merge request merges it rather than being mistaken for something else.

type ScreenHints

type ScreenHints interface{ Hints() []Hint }

ScreenHints is the compact key line the footer shows for this screen. Without it the footer falls back to the screen's actions, cut to fit.

type Size added in v0.4.1

type Size struct{ Min, Max, Margin int }

Size is a dialog's width band: never narrower than Min, never wider than Max, always Margin short of the terminal, so what is underneath stays visible at the edges.

type Starter

type Starter interface{ Init() tea.Cmd }

Starter runs work at startup, alongside the first frame's load.

type Step added in v0.6.0

type Step struct {
	// Title names the step in the progress line and in the dialog's title.
	Title string
	// Body renders the step at the dialog's inner width.
	Body func(width int) string
	// Keys are the step's own, live while the focus is on its content. They are
	// asked on every render, like a dialog's.
	Keys func() []DialogKey
	// OnKey receives what the step's keys do not claim — typing into a field, a
	// picker's filter.
	OnKey func(tea.KeyMsg) tea.Cmd
	// Typing marks a step collecting what is typed into it, so it claims the
	// keys the shell would otherwise answer.
	Typing bool
	// Enter runs when the step is arrived at, forwards or backwards: loading
	// what it offers, seeding it from what the last step produced.
	Enter func() tea.Cmd
	// Leave runs before moving on, and refuses with a reason. That is where a
	// step says a name is taken or that nothing has been picked — at the moment
	// of moving on, which is when it matters and where the answer is visible.
	Leave func() error
}

Step is one thing a wizard asks for.

type Wizard added in v0.6.0

type Wizard struct {
	// Title names the whole flow: "new stack".
	Title string
	// Steps in the order they are asked, at least one.
	Steps []Step
	// Finish labels the forward button on the last step: "create", "save".
	Finish string
	// Danger marks a wizard that ends in something irreversible, colouring the
	// button that finishes it the way an error is coloured rather than the way
	// a choice is — the same mark a Question puts on its yes.
	//
	// Only that button: "next" moves between steps and undoes nothing, so
	// colouring it would cry wolf on every step but the last.
	Danger bool
	// OnFinish runs when the last step is confirmed, after the dialog closes.
	OnFinish func() tea.Cmd
	// contains filtered or unexported fields
}

Wizard is a flow asked one step at a time, in a single dialog.

A form is right when everything fits on one screen and can be filled in any order. A wizard is right when it cannot: when the second question is about what the first one produced, and the last is "is this right?". Stacking a dialog per step would say they are separate things; they are one errand.

func (*Wizard) At added in v0.6.0

func (w *Wizard) At() int

At is the step showing, counted from zero.

func (*Wizard) Back added in v0.6.0

func (w *Wizard) Back() tea.Cmd

Back returns to the previous step, keeping everything both of them hold.

func (*Wizard) Next added in v0.6.0

func (w *Wizard) Next() tea.Cmd

Next moves on, unless the step says why it cannot be left. The last step's forward button finishes.

func (*Wizard) Step added in v0.6.0

func (w *Wizard) Step() *Step

Step is the step showing.

type Wrapper added in v0.4.0

type Wrapper interface{ Unwrap() Screen }

Wrapper is a screen that stands in for another — an application that adds its own keys to one of the shell's screens embeds it and pushes the wrapper. A message addressed to the inner screen is still meant for it, so the shell looks through the wrapper rather than dropping the answer on the floor.

Directories

Path Synopsis
Package board is a kanban screen: cards in lanes, grouped by a field the application names, with the cursor moving between them and cards moving between lanes.
Package board is a kanban screen: cards in lanes, grouped by a field the application names, with the cursor moving between them and cards moving between lanes.
examples
browse command
Command browse is a ninebox application that browses the filesystem.
Command browse is a ninebox application that browses the filesystem.
Package markdown renders markdown as styled terminal text.
Package markdown renders markdown as styled terminal text.
Package text is width-aware text handling: the measuring, padding and clipping a terminal table needs, with no colour and no layout in it.
Package text is width-aware text handling: the measuring, padding and clipping a terminal table needs, with no colour and no layout in it.
Package theme is the skin: a palette, the text styles drawn from it, and the two frames — a pane and a card — that everything on screen is drawn inside.
Package theme is the skin: a palette, the text styles drawn from it, and the two frames — a pane and a card — that everything on screen is drawn inside.

Jump to

Keyboard shortcuts

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