clikit

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 15 Imported by: 0

README

cli-kit

A shared visual layer for Bubble Tea CLIs — one palette, layout primitives, and an opt-in agent PromptBox behind a small capability contract, so every tool built on it looks and feels like it came from the same dev.

Built for and extracted from code (a launcher for oh-my-pi) and the atyrode cockpit in atyrode/dotfiles, which remain its reference consumers.

go get github.com/atyrode/cli-kit

Packages

Package What it gives you Pulls in
clikit (root) palette & styles, layout/panel/meter/workspace primitives, the PromptBox, Run, the capability contract lipgloss, Bubble Tea, bubbles
clikit/omp Ask/Act backends that shell out to a headless oh-my-pi run stdlib only (omp binary needed at runtime)
clikit/ollama an Act backend speaking to a local ollama daemon over loopback HTTP stdlib only (daemon needed at runtime)

The backends are deliberately separate packages: importing the core never buys you a model runner. Nothing in this library installs, launches, or assumes an LLM — a tool that wants the PromptBox opts in by implementing an interface, and a tool that wants a local model opts in by importing clikit/ollama and bringing its own daemon.

The core

  • Palette & styles (palette.go) — colour tokens (CAcc, CBord, …), the MeterRamp, text-presentation glyphs (GWarn/GBroken/GReset), and shared lipgloss styles (StDim, StHead, …).
  • Layout and section helpers (layout.go, meter.go) — PadLeft/Pad, WindowList (a clipped, scrollbar'd column), Scrollbar, meters, and full-width Rule/SeparatedSections boundaries. Widths are terminal cells; empty sections produce no orphan rule.
  • Workspace and panel primitives (workspace.go, panel.go) — WorkspaceNav owns ordered selection, wrapping next/previous navigation, and stable application-defined IDs; Panel, PanelContentWidth, and ClipLines provide shared rounded chrome with terminal-cell clipping and no wrap-driven footer displacement.
  • Footer conventions (palette.go, layout.go) — NewHelp applies the shared key/description/separator palette to Bubble Help, while WrapHelp wraps complete required cues without dropping them. Both remain ANSI-aware through lipgloss width measurement.
  • The PromptBox (promptbox.go) + the consumer contract (contract.go, run.go) + ParseActions (actions.go), the tolerant model-output → action-set parser every Act backend shares.

The consumer contract

A CLI built on cli-kit is a plain Bubble Tea model (tea.Model). It launches through clikit.Run, which mounts capabilities the model opts into by implementing small interfaces — structural, compile-time, à la carte:

Implement Get
(nothing extra) palette + layout + the footer/help conventions
AskableAsker() Asker the PromptBox in Ask mode (read-only Q&A)
CommandableCommander() Commander the PromptBox in Act mode (propose → live-preview → keep/revert)
DocumentedDocs() DocCorpus grounding injected into the backend's system prompt

clikit.Run(app) detects what's implemented and mounts the box behind a toggle key (default ctrl+o). The same key or esc closes it and cancels any in-flight request. Act mode takes precedence over Ask when both are present. Options: WithToggleKey, WithAltScreen, WithMouseCellMotion, WithMessageFilter.

Ask mode
type helpApp struct{ /* your tea.Model */ }

func (helpApp) Asker() clikit.Asker     { return omp.NewAsker(myDocs) }
func (helpApp) Docs() clikit.DocCorpus  { return myDocs }

func main() { _, _ = clikit.Run(helpApp{}, clikit.WithAltScreen()) }

omp.NewAsker shells out to a headless oh-my-pi run (omp -p --mode text --no-session --no-tools --model claude-haiku-4-5 --append-system-prompt <docs> <prompt>) and streams the answer; dismissing the box kills the subprocess. Set Asker.ReplaceSystem for a narrow grounded assistant that must replace the coding-agent prompt and its managed scaffolding. Override the evaluator via Asker.Model.

Act mode

Implement clikit.Commander — two-phase so the box can show the model working:

type Commander interface {
    Propose(ctx context.Context, prompt string) (<-chan string, error) // stream raw output
    Parse(output string) ([]Action, error)                             // completed output → typed actions
}

The box streams Propose, runs Parse, and emits ActionsProposedMsg — the host applies the actions immediately (live preview) and snapshots its prior state. enter emits ActionsConfirmedMsg (keep; it carries the original prompt, so a launcher can forward it), esc emits ActionsRevertedMsg (restore the snapshot). Apply actions through the same code path as a manual change — the closed Action{Key, Value} set is your tool's agent-facing API surface.

Backends: omp.Commander (headless oh-my-pi turn, scaffolding stripped) or ollama.Commander (local daemon, temperature 0, JSON-constrained output). Both parse with clikit.ParseActions, which tolerates prose around the JSON object.

Local-model residency (Loadable)

A backend that also implements clikit.Loadable (as ollama.Commander does) lets the box offer a load/unload toggle (ctrl+l) and show residency state — the model occupies RAM only when the user chooses, and a one-off suggestion never silently leaves weights resident.

Design notes

The original design record (rationale, the runtime-mutation wall, phasing) is kept as DESIGN-promptbox.md — a historical document; where it disagrees with the code, the code wins.

Building / testing

go build ./... && go test ./...

Plain Go module, no code generation, no CGO. CI runs gofmt, go vet, and the test suite.

License

MIT

Documentation

Overview

Package clikit is a shared visual layer for terminal CLIs — one palette and a set of lipgloss/Bubble Tea helpers, plus an opt-in agent PromptBox behind a small capability contract, so every tool built on it looks and feels like it came from the same dev. Extracted from its first proven consumer (code, the oh-my-pi launcher) and grown as new CLIs need more.

Index

Constants

View Source
const (
	CDim     = "#78829b"
	CGrp     = "#69727e"
	CAcc     = "#ff9f52"
	CBord    = "#3a4453"
	CHead    = "#9aa4b1"
	CSelBg   = "#1b212b"
	CGptSoft = "#6e91be"
	CClaSoft = "#c3a078"
	CRed     = "#d05c60"
	CGreen   = "#78c8aa"
	CEmpty   = "#404757" // unused meter pips — dimmer than CDim, so the fill reads
)

Palette — colour tokens and semantic roles shared across the CLIs.

View Source
const (
	GWarn   = "⚠︎"
	GBroken = "✗︎"
	GReset  = "↻︎"
)

Text-presentation glyphs (trailing U+FE0E) so terminals render them 1-cell, matching the width layout math assumes.

View Source
const (
	GMemory    = "" //  nf-fa-memory — the local model chip
	GToggleOn  = "" //  resident in RAM
	GToggleOff = "" //  evicted
)

Nerd Font glyphs for the model-residency indicator: a memory chip plus an on/off toggle. Require a Nerd Font (the CLIs already assume one).

Variables

View Source
var (
	StDim    = lipgloss.NewStyle().Foreground(lipgloss.Color(CDim))
	StGrp    = lipgloss.NewStyle().Foreground(lipgloss.Color(CGrp))
	StHead   = lipgloss.NewStyle().Foreground(lipgloss.Color(CHead))
	StWarn   = lipgloss.NewStyle().Foreground(lipgloss.Color(CAcc))
	StBrk    = lipgloss.NewStyle().Foreground(lipgloss.Color(CRed))
	StOk     = lipgloss.NewStyle().Foreground(lipgloss.Color(CGreen))
	StStruck = lipgloss.NewStyle().Strikethrough(true).Faint(true).Foreground(lipgloss.Color(CDim))
)

Shared styles built from the palette.

View Source
var MeterRamp = [6]string{"", CGreen, "#a6c56e", "#d8c368", "#d89a5c", CRed}

MeterRamp colours a 1..5 meter green→red; index 1 is best (green), 5 is worst (red). A "higher is better" meter reverses the lookup (6-n) so fast/good reads green and slow/bad reads red.

Functions

func ClipLines

func ClipLines(content string, width int) string

ClipLines truncates every physical row to width without wrapping.

func Meter

func Meter(label, glyph, fill string, n int) string

Meter renders a labelled 1..5 scale — n glyphs in the fill colour, the rest in the dim "empty" colour — always five glyphs, so the fill (and the headroom) read at a glance. Pair with MeterRamp to colour the fill by score.

func NewHelp

func NewHelp() help.Model

NewHelp returns Bubble Tea's help model with the shared CLI palette applied. Consumers keep their own key maps and layout policies while keys, descriptions, separators, and truncation use one visual language.

func Pad

func Pad(s string, n int) string

Pad right-pads s with spaces to at least width n.

func PadLeft

func PadLeft(s string, n int) string

PadLeft indents every line of s by n spaces. Safe on styled (ANSI) strings — the spaces sit before any escape codes.

func Panel

func Panel(width int, content string) string

Panel renders clipped content inside cli-kit's shared rounded chrome. The returned block never exceeds width, preventing terminal auto-wrap from moving footers below the viewport.

func PanelContentWidth

func PanelContentWidth(width int) int

PanelContentWidth reports the usable content width inside Panel.

func Rule

func Rule(width int) string

Rule renders a full-width section boundary using the shared visual palette.

func Run

func Run(app App, opts ...RunOption) (tea.Model, error)

Run starts app under a cli-kit host that auto-mounts capabilities: Askable gets a prompt box in Ask mode, Commandable in Act mode (precedence), Documented grounds the backend; an app implementing none simply runs as-is. It returns the app's final model (unwrapped from the host) so callers can read end state.

func Scrollbar

func Scrollbar(total, h, start int) string

Scrollbar renders a 1-column, h-line track with a proportional thumb; when the content fits (total ≤ h) it is a blank column, keeping the layout width stable.

func SeparatedSections

func SeparatedSections(width int, sections ...string) string

SeparatedSections stacks every non-empty section behind a full-width Rule. The result always begins with a boundary, so callers can attach it below any independently laid-out body without allowing adjacent sections to run together. Empty sections are omitted along with their boundary.

func WindowList

func WindowList(lines []string, cursor, h, w int) string

WindowList clips a list to h lines, scrolled to keep the cursor visible, fixes the width, and appends a 1-column scrollbar (blank when everything fits) — so the column is always exactly h lines tall and w wide, and shows its scroll pos.

func WrapHelp

func WrapHelp(h help.Model, width int, items []HelpItem) string

WrapHelp renders every control using the supplied shared help model and wraps whole cues across rows without dropping any. This complements Bubble Help's intentionally truncating single-line renderer for screens whose controls are all required, such as modal managers.

Types

type Action

type Action struct {
	Key   string
	Value string
}

Action is one host-validated mutation the agent proposes in Act mode. Its meaning is the host's: for the code generator an Action is {facet, value}. The closed set a Commander returns IS the tool's agent-facing API surface.

func ParseActions

func ParseActions(output string) ([]Action, error)

ParseActions extracts the JSON object from a model's output (tolerating any surrounding prose) and turns it into a key-sorted Action set — sorted so the proposal is deterministic regardless of JSON/map ordering. Backends share it so every Commander parses proposals the same way.

type ActionsConfirmedMsg

type ActionsConfirmedMsg struct{ Prompt string }

ActionsConfirmedMsg is emitted when the user KEEPS the applied proposal. Prompt is the text they submitted, so a host can carry it forward (e.g. as the first message of the session it launches). The actions were already applied via ActionsProposedMsg.

type ActionsProposedMsg

type ActionsProposedMsg struct{ Actions []Action }

ActionsProposedMsg is emitted as soon as a proposal is parsed. The host applies it immediately as a live preview (saving prior state), so the change is visible in the tool's own UI while the box shows keep/revert.

type ActionsRevertedMsg

type ActionsRevertedMsg struct{}

ActionsRevertedMsg is emitted when the user rejects the applied proposal; the host restores the state it saved on ActionsProposedMsg.

type App

type App interface {
	tea.Model
}

App is the baseline a cli-kit TUI implements: a Bubble Tea model. It opts into extra capabilities (Askable/Commandable/Documented) by also implementing those interfaces; Run detects them and mounts the matching UI.

type AppliedActionsMsg

type AppliedActionsMsg struct{ Actions []Action }

AppliedActionsMsg lets the host replace what the box lists as "applied" with the authoritative set it actually applied — which may differ from the parsed proposal (e.g. the host derives extra changes, or repairs/validates some). Optional: a host that applies actions verbatim need never send it.

type Askable

type Askable interface{ Asker() Asker }

Askable enables the PromptBox in read-only Ask mode.

type Asker

type Asker interface {
	Ask(ctx context.Context, prompt string) (<-chan string, error)
}

Asker answers a prompt read-only, streaming the answer as string chunks on the returned channel and closing it when the answer is complete. Cancelling ctx MUST stop the underlying work (e.g. kill the omp subprocess) and close the channel; a non-nil error reports a failure to start.

type BoxCloseMsg

type BoxCloseMsg struct{}

BoxCloseMsg is emitted when the user dismisses an idle box (esc while editing). A host (see Run) listens for it to hide the box.

type Commandable

type Commandable interface{ Commander() Commander }

Commandable additionally enables Act mode (propose → diff → confirm).

type Commander

type Commander interface {
	Propose(ctx context.Context, prompt string) (<-chan string, error)
	Parse(output string) ([]Action, error)
}

Commander proposes changes for a prompt in two parts so the box can show the model working: Propose streams the model's raw output (rationale + result) for live display, and Parse turns the completed output into a closed set of typed actions the host validates and applies. Cancelling ctx stops the work.

type DocCorpus

type DocCorpus string

DocCorpus is grounding text a Documented host exposes; a real Asker backend injects it into the model's system prompt so answers are tool-specific.

type Documented

type Documented interface{ Docs() DocCorpus }

Documented supplies grounding for Ask/Act.

type HelpItem

type HelpItem struct {
	Key         string
	Description string
}

HelpItem is one reusable key/description pair in a TUI control footer.

type Loadable

type Loadable interface {
	Load(ctx context.Context) error
	Unload(ctx context.Context) error
	Loaded(ctx context.Context) (bool, error)
}

Loadable is an optional backend capability for a local model that can be held resident in memory. A backend that implements it lets the PromptBox offer a toggle key (load/unload) and show residency state, so the user decides when the model occupies RAM rather than it lingering unbidden. Load pins the model, Unload evicts it, and Loaded reports whether it is resident now. All three take a context so the call can be cancelled/timed out.

type MessageFilter

type MessageFilter func(tea.Model, tea.Msg) tea.Msg

MessageFilter runs before Bubble Tea dispatches a message. Returning nil drops the message before Update and View, which is useful for coalescing high-rate input that would otherwise trigger redundant redraws. The model passed to the filter is the consumer app, not cli-kit's host wrapper.

type PromptBox

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

PromptBox is a value type — Update returns an updated copy, matching the bubbles convention.

func NewPromptBox

func NewPromptBox() PromptBox

NewPromptBox builds a box in the editing state. Call SetAsker to give it a backend and SetSize before rendering.

func (PromptBox) Busy

func (b PromptBox) Busy() bool

Focused reports whether the box currently owns keyboard input.

func (*PromptBox) Focus

func (b *PromptBox) Focus() tea.Cmd

Focus re-focuses the input; call when (re)opening the box.

func (PromptBox) Init

func (b PromptBox) Init() tea.Cmd

Init starts the spinner ticking.

func (*PromptBox) SetAsker

func (b *PromptBox) SetAsker(a Asker)

SetAsker wires the read-only backend. Without one, submitting is a no-op. A backend that is also Loadable enables the load/unload toggle.

func (*PromptBox) SetCommander

func (b *PromptBox) SetCommander(c Commander)

SetCommander wires the Act-mode backend. When set, submitting asks the Commander for a proposal (streamed live, then shown for confirmation) instead of streaming a plain answer; it takes precedence over an Asker. A Commander that is also Loadable enables the load/unload toggle.

func (*PromptBox) SetSize

func (b *PromptBox) SetSize(w, maxH int)

SetSize lays the box out within w outer cells, with maxH the tallest the box may grow to (the host caps it so it never eats the whole screen). The box is content-sized: the input grows with what's typed and the answer pane fits its text, so an idle box is a single line rather than a half-screen panel.

func (*PromptBox) SetTitle

func (b *PromptBox) SetTitle(s string)

SetTitle sets an optional header line (e.g. "prompt → profile · gpt-5.6-luna") so the user can see what the box is doing / which model it uses.

func (PromptBox) Update

func (b PromptBox) Update(msg tea.Msg) (PromptBox, tea.Cmd)

Update advances the box. It returns a possibly-updated copy plus a Cmd. When the user dismisses an idle box, the Cmd emits BoxCloseMsg for the host.

func (PromptBox) View

func (b PromptBox) View() string

View renders the box: a prompt input, then either a "thinking" line, the streamed answer, or an error.

type RunOption

type RunOption func(*host)

RunOption configures Run.

func WithAltScreen

func WithAltScreen() RunOption

WithAltScreen runs the program in the alternate screen buffer.

func WithMessageFilter

func WithMessageFilter(filter MessageFilter) RunOption

WithMessageFilter installs a pre-dispatch Bubble Tea message filter.

func WithMouseCellMotion

func WithMouseCellMotion() RunOption

WithMouseCellMotion enables cell-motion mouse reporting (e.g. wheel scroll).

func WithToggleKey

func WithToggleKey(k string) RunOption

WithToggleKey overrides the key that opens the prompt box (default "ctrl+o").

type WorkspaceID

type WorkspaceID string

WorkspaceID identifies one persistent destination in a TUI shell. IDs are application-defined; cli-kit only owns ordering and navigation semantics.

type WorkspaceItem

type WorkspaceItem struct {
	ID       WorkspaceID
	Label    string
	Shortcut string
}

WorkspaceItem describes one destination shown by a TUI shell.

type WorkspaceNav

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

WorkspaceNav is a domain-free, persistent workspace navigator. It deliberately owns no rendering or Bubble Tea messages so applications can keep their local state while sharing one predictable navigation model.

func NewWorkspaceNav

func NewWorkspaceNav(items ...WorkspaceItem) WorkspaceNav

NewWorkspaceNav constructs a navigator in declaration order. Empty and duplicate IDs are ignored so Active always identifies an unambiguous item.

func (WorkspaceNav) Active

func (n WorkspaceNav) Active() WorkspaceID

Active returns the selected workspace ID, or the empty ID for an empty nav.

func (WorkspaceNav) ActiveItem

func (n WorkspaceNav) ActiveItem() (WorkspaceItem, bool)

ActiveItem returns the selected declaration and whether one exists.

func (WorkspaceNav) Items

func (n WorkspaceNav) Items() []WorkspaceItem

Items returns a copy of the ordered workspace declarations.

func (*WorkspaceNav) Next

func (n *WorkspaceNav) Next() WorkspaceID

Next advances one destination, wrapping at the end.

func (*WorkspaceNav) Previous

func (n *WorkspaceNav) Previous() WorkspaceID

Previous moves back one destination, wrapping at the beginning.

func (*WorkspaceNav) Select

func (n *WorkspaceNav) Select(id WorkspaceID) bool

Select activates id. Unknown IDs leave the navigator unchanged.

Directories

Path Synopsis
Package ollama provides a clikit Act backend that talks to a local ollama daemon over HTTP.
Package ollama provides a clikit Act backend that talks to a local ollama daemon over HTTP.
Package omp provides clikit backends that shell out to the oh-my-pi CLI (https://github.com/can1357/oh-my-pi).
Package omp provides clikit backends that shell out to the oh-my-pi CLI (https://github.com/can1357/oh-my-pi).

Jump to

Keyboard shortcuts

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