ui

package
v0.0.0-...-e64921a Latest Latest
Warning

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

Go to latest
Published: May 9, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Overview

Package ui hosts canopy's Bubbletea TUI. The TUI is the front door when `canopy` is invoked with no subcommand — it shows the list of workspaces (plus the main session if alive), lets the user navigate, attach, create, and remove without leaving the visual surface.

Architecture: the Model holds a snapshot of state (loaded via workspace.Manager.List + the main-session check) and a cursor. Every keypress maps to an Update that mutates the Model, possibly returning a tea.Cmd to fetch fresh data or hand off to tmux. The View renders the Model into a styled table via lipgloss.

We deliberately keep the Manager + state.Store wiring outside this package — Model takes a *workspace.Manager and dispatches to it. That keeps internal/ui from owning the lifecycle, mirroring how cmd/canopy/* subcommands work.

In-TUI upgrade flow. Triggered by the `U` key on the workspace list when the auto-check pill is showing. Four states:

loading  → CHANGELOG fetch in flight
preview  → CHANGELOG visible, "Enter to upgrade, Esc to cancel"
running  → git pull + make install streaming live
doneOK   → success, "press any key to dismiss"
doneError → failure with stderr tail, "press any key to dismiss"

Reuses safeBuffer + progressTick from busyMode for the streaming pane — same producer/consumer pattern, no new dep, identical rendering shape. The CLI's `canopy upgrade` flow is unchanged: it stays plain stdout. The TUI flow is in-TUI only and reachable via the U key when an upgrade is available; pressing U with no upgrade is a silent no-op.

External integration: cmd/canopy supplies two closures via SetUpgradeChangelogFn / SetUpgradeShellFn. The UI doesn't import cmd/canopy (cycle); the closures bridge the network and shell layers without leaking package internals across the boundary.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(mgr *workspace.Manager) error

Run is the legacy project-mode entry point. RunUnified is the v0.8 unified entry; Run is preserved as a thin wrapper for any external callers (none today) and the e2e tests in the workspace package.

Pre-v0.8 Run had an exit-7 signal channel for the popup-from-project nested-canopy flow. That flow is gone — popup hosts the unified TUI directly, no nested spawn — so this is now a straightforward Bubbletea run-loop wrapper.

func RunInitSplash

func RunInitSplash(cwd string) (bool, error)

RunInitSplash launches the splash. Returns (didInit, err): didInit is true iff the user pressed 'i' (the caller should then run init); false means the user dismissed without initializing. err surfaces any tea.Program failure, which is rare.

func RunUnified

func RunUnified(mgr *workspace.Manager, store *state.Store, tc *tmux.Client, currentProject, currentWorkspaceRoot, currentWorkspace string, opts RunUnifiedOptions) error

RunUnified is the v0.8 public entry point used by cmd/canopy/route.go. Single bubbletea program for every canopy invocation: project, global, popup. mgr is optional — nil when invoked from outside a registered project. currentProject is the resolved Local-tab filter root.

Optional knobs (version pill, auto-check, in-TUI upgrade flow) live in RunUnifiedOptions to keep the positional signature short and guard against argument-order bugs as more features land. Pass the zero value for the bare TUI.

In popup mode (CANOPY_IN_POPUP=1) we omit MouseCellMotion since the popup is keyboard-driven and mouse handling adds latency.

Types

type Binding

type Binding struct {
	K         key.Binding
	Available func(*Model) bool
	Action    func(*Model, tea.KeyMsg) (tea.Model, tea.Cmd)
}

Binding is the unified TUI's keybinding shape. K provides the key literals + help text; Available is the runtime visibility predicate; Action runs when the key matches AND Available is true (or nil).

Action returns (tea.Model, tea.Cmd) rather than just tea.Cmd because canopy's existing handler shape mutates the Model and may need to return tea.Quit, tea.Batch, or a custom message. Wrapping with a uniform single-return signature would force every handler through an awkward closure dance for what's already idiomatic Bubbletea.

func (Binding) IsAvailable

func (b Binding) IsAvailable(m *Model) bool

IsAvailable returns Available's result, or true when Available is nil. Used by the help-line renderer to filter unavailable bindings out.

func (Binding) Matches

func (b Binding) Matches(msg tea.KeyMsg, m *Model) bool

Matches reports whether the pressed key is one of K's keys AND the binding is currently available. nil Available means "always available" — most bindings.

type InitSplashModel

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

InitSplashModel is the read-only init prompt. State is small: just the cwd we'll init when the user presses 'i', plus the didInit flag the caller checks after Run returns.

We deliberately do NOT call runInit from inside the Bubbletea program. Instead, 'i' sets didInit=true and triggers tea.Quit. The caller (cmd/canopy/route.go) sees the flag, exits altscreen cleanly, and then runs runInit synchronously so its output prints to a normal terminal (post-altscreen). This avoids the Bubbletea-inside-Bubbletea trap that mid-program Model swaps create.

func NewInitSplash

func NewInitSplash(cwd string) *InitSplashModel

NewInitSplash constructs an InitSplashModel for the given cwd. The path is shown to the user in the prompt copy so they can confirm they're about to init the right place.

func (*InitSplashModel) Init

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

Init implements tea.Model. No startup work — splash is purely reactive.

func (*InitSplashModel) Update

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

Update implements tea.Model. Only reacts to keypresses: 'i' opts into init and quits, 'q' / ctrl+c quits without init, everything else is ignored (so a stray modifier doesn't accidentally dismiss the splash).

func (*InitSplashModel) View

func (m *InitSplashModel) View() string

View implements tea.Model. Single screen: title + cwd context + two- key prompt. Soft tone — this is an onboarding moment, not an error.

type Model

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

Model is the Bubbletea state. Constructed via New() (project mode, mgr non-nil) or NewUnified() (mgr-optional, used for popup + global invocations). Updated via Update(), rendered via View().

v0.8 unification: the same Model serves three contexts — project TUI (mgr non-nil, single-project rows), global TUI (mgr nil, cross-project rows), and popup mode (CANOPY_IN_POPUP=1, single-line tab bar + switch-client attach). The viewMode + popup* fields drive the runtime dispatch.

func New

func New(mgr *workspace.Manager) *Model

New constructs a project-mode Model. mgr is required. Used by the project TUI entry path (when canopy.json walk-up succeeds).

Wraps NewUnified with the project-mode defaults: tabLocal pre-selected, currentProject = mgr.Cfg.ProjectRoot.

func NewUnified

func NewUnified(mgr *workspace.Manager, store *state.Store, tc *tmux.Client, currentProject, currentWorkspaceRoot, currentWorkspace string) *Model

NewUnified is the v0.8 unified-TUI constructor. Single entry point for every canopy invocation: project, global, popup. mgr is optional — nil when the user invoked canopy from outside any registered project or from a popup whose host pane isn't in a known project.

currentProject is the canonical ProjectRoot for the Local tab filter (resolved upstream by workspace.ResolveCurrentProject); empty disables Local-tab filtering.

Popup-mode rendering is detected via CANOPY_IN_POPUP=1 (set by the tmux display-popup -E invocation in install_tmux.go). Single source of truth: the env var is what flips chrome from fullscreen to popup.

func (*Model) Init

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

Init implements tea.Model. Returns the initial command — load the workspace list as soon as the program starts. The refresh path is dual: when mgr is non-nil it uses mgr.List + mgr.Reconcile (project mode); when nil it falls back to state.BuildGlobalRows (global + popup-without-project mode). Either way the result lands in m.allRows; tab + search filtering happens on every render.

func (*Model) SetUpgradeAvailable

func (m *Model) SetUpgradeAvailable(latest string)

SetUpgradeAvailable records the bare semver of an available newer canopy release. Empty string suppresses the upgrade-arrow branch on the version pill. Caller is responsible for the gating logic (DEV-binary check, dismissal, version equality) — this setter just stores the value the renderer should display.

Called by RunUnified on startup (sync read from ~/.canopy/upgrade-check.json) and updated mid-session by the upgradeCheckedMsg handler when the async refresh lands.

func (*Model) SetUpgradeChangelogFn

func (m *Model) SetUpgradeChangelogFn(fn UpgradeChangelogFn)

SetUpgradeChangelogFn wires the network closure used for the preview state. Pass nil to disable the in-TUI flow entirely (pressing U becomes a silent no-op even when the pill shows).

func (*Model) SetUpgradeDismissFn

func (m *Model) SetUpgradeDismissFn(fn UpgradeDismissFn)

SetUpgradeDismissFn wires the persistence closure for the D key (TUI dismissal). Implementation lives in cmd/canopy because the cache file path + write logic are package-main details.

func (*Model) SetUpgradeRefreshFn

func (m *Model) SetUpgradeRefreshFn(fn UpgradeRefreshFn)

SetUpgradeRefreshFn wires the async refresh closure that fires from Init() when the auto-check cache was missing or stale at startup. Pass nil to skip refresh entirely (tests, popup mode where we want minimal startup work, etc.).

func (*Model) SetUpgradeShellFn

func (m *Model) SetUpgradeShellFn(fn UpgradeShellFn)

SetUpgradeShellFn wires the shell closure used for the running state. Same nil-suppression as SetUpgradeChangelogFn — both must be set for the U key to work.

func (*Model) SetVersionInfo

func (m *Model) SetVersionInfo(versionLabel, devWorkspace string)

SetVersionInfo records the running binary's version surface for the top-bar pill. Called by cmd/canopy after constructing the model so the UI never has to know about ldflags or BuildInfo — it just renders the strings it's given. Safe to call with all-empty arguments to suppress the pill (e.g., in tests that don't care about chrome).

func (*Model) Update

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

Update implements tea.Model. Routes incoming messages to focused handlers. The Model is always returned by value — Bubbletea owns the "current" Model, we own the next one.

func (*Model) View

func (m *Model) View() string

View implements tea.Model. Renders the current Model into a string. Sections: title, table, status/help line, optional error banner.

type Row

type Row = state.GlobalRow

Row is a back-compat alias for state.GlobalRow. v0.8 unification promoted state.GlobalRow to the canonical row shape (it's what the projectlist sub-component renders) and ui.Row went away. Tests in the package still write `ui.Row{...}` literals; the alias keeps those compiling without rewriting every test.

type RunUnifiedOptions

type RunUnifiedOptions struct {
	// VersionLabel is the human-friendly version string for the
	// top-bar pill ("v0.13.0+abc1234"). Empty suppresses the pill.
	VersionLabel string

	// DevWorkspace is the canopy workspace name when the running
	// canopy is a DEV build inside a known worktree. Non-empty
	// triggers the cyan DEV pill regardless of VersionLabel.
	DevWorkspace string

	// InitialUpgrade is the bare semver of an available newer
	// canopy release, read synchronously from the auto-check cache
	// at startup. Empty when no upgrade is available, the cache
	// is missing, the user has dismissed, or running on DEV.
	InitialUpgrade string

	// RefreshFn is the async closure that performs the network
	// fetch + cache write. Result lands as upgradeCheckedMsg and
	// updates the pill mid-session. Wired unconditionally so the
	// `r` key can force a refresh; Init() only fires it on launch
	// when RefreshOnInit is also true. Nil disables refresh.
	RefreshFn UpgradeRefreshFn

	// RefreshOnInit gates whether Init() fires RefreshFn at TUI
	// launch. True when the auto-check cache was stale or missing
	// at construction (caller derived this from initialUpgradeForUI).
	// False when the cache was fresh — startup uses the cached
	// value and skips the network call. The `r` key fires RefreshFn
	// regardless of this flag.
	RefreshOnInit bool

	// ChangelogFn fetches the CHANGELOG slice for the in-TUI
	// upgrade flow's preview state. Nil disables the U key.
	ChangelogFn UpgradeChangelogFn

	// ShellFn runs git pull + make install for the in-TUI upgrade
	// flow's running state. Nil disables the U key.
	ShellFn UpgradeShellFn

	// DismissFn writes dismissed_version into the auto-check cache
	// for the D key. Nil disables D.
	DismissFn UpgradeDismissFn
}

RunUnifiedOptions groups the optional knobs passed into RunUnified. All fields are optional; the zero value gives the bare TUI with no version pill, no auto-check, no in-TUI upgrade flow.

Lives next to RunUnified rather than being separately documented because it exists solely as RunUnified's options bag — when the next field is added, it lands here and RunUnified's call sites don't shift positionally.

type UpgradeChangelogFn

type UpgradeChangelogFn func(ctx context.Context) (preview string, err error)

UpgradeChangelogFn fetches the CHANGELOG slice between the running version and latest. Returns the rendered preview text or "" when the fetch fails (best-effort — preview is informational, the flow proceeds regardless). Network errors surface for logging.

type UpgradeDismissFn

type UpgradeDismissFn func() error

UpgradeDismissFn writes dismissed_version into the auto-check cache so the pill stops showing for the current release. Returns an error if the cache write fails; the caller decides whether to surface it (today the TUI just logs and clears the in-memory pill anyway).

type UpgradeRefreshFn

type UpgradeRefreshFn func(ctx context.Context) (latest string, err error)

UpgradeRefreshFn performs the async cache refresh: fetches the latest VERSION from upstream, writes the cache, returns the upgrade-available semver (or "" when the user is up to date or has dismissed the latest). Network errors surface here so the caller (the UI) can log; the UI does NOT change pill state on error — the existing cached value stays visible.

type UpgradeShellFn

type UpgradeShellFn func(ctx context.Context, w io.Writer) error

UpgradeShellFn runs `git pull --ff-only && make install` and pipes stdout/stderr into the writer. Cancellable via ctx (Ctrl-C in the running state). Returns the underlying error on shell failure; success is err == nil.

The writer is io.Writer (not *safeBuffer) so cmd/canopy can supply the closure without depending on the unexported safeBuffer type. Internally we always pass a *safeBuffer (it satisfies io.Writer); the seam is just for keeping internal/ui types out of cmd/canopy.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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