tui

package
v1.52.1 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 44 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrDestUnreachable = errors.New("no connection for that destination")

ErrDestUnreachable is what a one-shot send reports when the router holds no connection for the destination. Send itself never reports it — see sendForDestStrict.

View Source
var ErrLinkPermanent = errors.New("remote link failure is permanent")

ErrLinkPermanent marks a dial failure that an identical retry cannot fix — a rejected key, a changed host key, an algorithm mismatch.

The dialer wraps it and the loop tests with errors.Is. A sentinel rather than a bool on redialResultMsg so the classification travels WITH the error it describes, and cannot be dropped by a future call site that forgets to copy a field.

View Source
var ErrRemoteQuilMissing = errors.New("quil is not installed on that host")

ErrRemoteQuilMissing marks a dial that reached the host but found no quil there. cmd/quil classifies it from the ssh child's EXIT CODE — 127 for a command the remote shell could not find — never from the message, which is locale-dependent and is also a string any shell can emit for its own reasons. This package only has to recognise the wrapped sentinel.

View Source
var ErrRemoteVersionMismatch = errors.New("the daemon on that host runs a different version")

ErrRemoteVersionMismatch marks a dial that found a WORKING quil on the host running a version this client refuses to attach to.

It cannot be derived from the exit code the way ErrRemoteQuilMissing is, and that is the whole reason it exists: quil ran over there, so the link delivered bytes, so ClassifyExit's established override answers RemedyNone for every code that follows. cmd/quil raises it from the version handshake instead — and ONLY when this client is the newer of the two, because provisioning pushes this client's own build and would otherwise downgrade a daemon other clients may share.

Functions

func BuildArgs

func BuildArgs(template []string, fields map[string]string) []string

BuildArgs expands {placeholder} tokens in an arg template using field values.

func LoadRecentCWDs added in v1.41.0

func LoadRecentCWDs(path string) []string

LoadRecentCWDs reads the recent-CWD list from a JSON file. Returns nil on a missing, symlinked, or corrupt file — a fresh, empty history is always a valid state. The result is capped at recentCWDMax so a hand-edited oversized file can't grow the pick list.

func MarshalLayout

func MarshalLayout(n *LayoutNode) (json.RawMessage, error)

MarshalLayout serializes a LayoutNode tree to JSON.

func SaveInstances

func SaveInstances(path string, store InstanceStore) error

SaveInstances writes the instance store to a JSON file atomically.

func SaveRecentCWDs added in v1.41.0

func SaveRecentCWDs(path string, list []string) error

SaveRecentCWDs writes the list to a JSON file atomically (.tmp + rename), mirroring the persistence pattern in instances.go.

Types

type BorderHit added in v1.36.0

type BorderHit struct {
	Node         *LayoutNode
	OX, OY, W, H int
}

BorderHit describes one draggable split line: the internal node whose Ratio a drag mutates, plus the node's region captured at collection time so ratio math survives mid-drag layout changes.

func (BorderHit) Contains added in v1.36.0

func (b BorderHit) Contains(x, y int) bool

Contains reports whether (x, y) lies in this node's split-line hit zone. H-splits: columns [bd-1, bd+1+padding] (the drawn line plus a right-side budget of 1+padding cells). V-splits: rows [bd-1-padding, bd+padding] (symmetric). Both span the node's full perpendicular extent.

type Client added in v1.45.0

type Client = tuiClient

Client is the exported spelling of tuiClient, so callers outside this package can write a RedialFunc: cmd/quil builds the reconnect dialer and cannot name an unexported type.

An alias rather than a second interface declaration — the two are the same type, so no conversion exists at any boundary and the internal name stays the one used throughout this file.

type DialFunc added in v1.47.0

type DialFunc func(dest string) (Client, error)

DialFunc dials a destination that is not connected yet. cmd/quil supplies it for the same reason it supplies RedialFunc: the ssh transport lives there and this package cannot name it.

It is a FACTORY over dest, not a per-destination closure like RedialFunc, because the whole point is dialling a host nobody has named before.

type Direction added in v1.3.0

type Direction int

Direction identifies one of the four spatial-navigation directions used by NavigateDirection.

const (
	DirLeft Direction = iota
	DirRight
	DirUp
	DirDown
)

type EditorPos

type EditorPos struct {
	Row int // 0-based line index in Lines[]
	Col int // 0-based rune column
}

EditorPos identifies a position in the editor's rune-indexed line space.

type EditorSel

type EditorSel struct {
	Anchor EditorPos // where selection started (fixed)
	Cursor EditorPos // where selection extends to (moves with keys)
}

EditorSel tracks a text selection within the TextEditor.

func (*EditorSel) ColRange

func (s *EditorSel) ColRange(row, lineRuneLen int) (startCol, endCol int)

ColRange returns the selected column range [startCol, endCol) for a given row. endCol is exclusive (one past the last selected rune) for easy slicing. Returns (-1, -1) if the row is not in the selection.

func (*EditorSel) IsEmpty

func (s *EditorSel) IsEmpty() bool

IsEmpty returns true if the selection has zero width (anchor == cursor).

func (*EditorSel) Normalized

func (s *EditorSel) Normalized() (start, end EditorPos)

Normalized returns start/end ordered top-to-bottom, left-to-right.

type HighlightMode

type HighlightMode int

HighlightMode selects which syntax highlighter the editor renders with.

const (
	// HighlightTOML applies TOML keyword/comment colouring (default — used by
	// the TOML editor accessible via F1 → Plugins).
	HighlightTOML HighlightMode = iota
	// HighlightPlain disables syntax colouring. Used by pane notes.
	HighlightPlain
)

type InstallFunc added in v1.47.0

type InstallFunc func(dest string) error

InstallFunc provisions quil on a host, for the offer raised when a dial comes back ErrRemoteQuilMissing. Supplied by cmd/quil, which owns the release fetch and the ssh push.

type InstanceStore

type InstanceStore map[string][]SavedInstance

InstanceStore holds saved instances keyed by plugin name.

func LoadInstances

func LoadInstances(path string) InstanceStore

LoadInstances reads the instance store from a JSON file. Returns an empty store if the file doesn't exist.

type LayoutNode

type LayoutNode struct {
	Pane  *PaneModel // non-nil for leaf nodes
	Split SplitDir   // meaningful only for internal nodes
	Ratio float64    // fraction allocated to Left child (0.0–1.0)
	Left  *LayoutNode
	Right *LayoutNode
}

LayoutNode is a binary tree node for pane layout. Leaf nodes hold a *PaneModel; internal nodes hold two children and a split direction.

func DeserializeLayout

func DeserializeLayout(s *SerializedNode, panes map[string]*PaneModel) *LayoutNode

DeserializeLayout reconstructs a LayoutNode tree from a SerializedNode tree. Panes are looked up by ID from the provided map. Missing panes become placeholder nodes (nil Pane) that should be pruned by the caller.

func NewLeaf

func NewLeaf(pane *PaneModel) *LayoutNode

NewLeaf creates a leaf node wrapping a pane.

func (*LayoutNode) CollectBorders added in v1.36.0

func (n *LayoutNode) CollectBorders(ox, oy, w, h int, out *[]BorderHit)

CollectBorders walks the layout tree with the same arithmetic as CollectRects and appends one BorderHit per internal node. Parents are emitted before children, so a reverse scan finds the deepest split line under a point (T-junction resolution).

func (*LayoutNode) CollectRects added in v1.3.0

func (n *LayoutNode) CollectRects(ox, oy, w, h int, out *[]PaneRect)

CollectRects walks the layout tree and appends a PaneRect for every leaf. Used by spatial pane navigation (TabModel.NavigateDirection) to pick the closest neighbor in a given direction without re-implementing the layout arithmetic.

func (*LayoutNode) FillPlaceholder

func (n *LayoutNode) FillPlaceholder(pane *PaneModel) bool

FillPlaceholder finds the first placeholder leaf (nil Pane) and fills it. Returns true if a placeholder was found and filled.

func (*LayoutNode) FindLeaf

func (n *LayoutNode) FindLeaf(paneID string) *LayoutNode

FindLeaf returns the leaf node with the given pane ID, or nil.

func (*LayoutNode) FindPaneAt

func (n *LayoutNode) FindPaneAt(x, y, ox, oy, w, h int) *PaneModel

FindPaneAt returns the pane at screen coordinates (x, y), given the node's origin (ox, oy) and dimensions (w, h). Mirrors resizeNode() split logic.

func (*LayoutNode) FindPaneRectAt

func (n *LayoutNode) FindPaneRectAt(x, y, ox, oy, w, h int) *PaneRect

FindPaneRectAt returns the pane and its screen rectangle at coordinates (x, y).

func (*LayoutNode) HasPlaceholder

func (n *LayoutNode) HasPlaceholder() bool

HasPlaceholder returns true if the tree contains a leaf with nil Pane.

func (*LayoutNode) IsLeaf

func (n *LayoutNode) IsLeaf() bool

IsLeaf returns true if this node holds a pane (no children).

func (*LayoutNode) Leaves

func (n *LayoutNode) Leaves() []*PaneModel

Leaves returns all panes via in-order traversal (left-to-right, top-to-bottom).

func (*LayoutNode) PaneIDs

func (n *LayoutNode) PaneIDs() map[string]bool

PaneIDs returns the set of all pane IDs in the tree.

func (*LayoutNode) PrunePlaceholders

func (n *LayoutNode) PrunePlaceholders() bool

PrunePlaceholders removes any placeholder leaves (nil Pane) by promoting siblings. Returns true if the tree was modified.

func (*LayoutNode) RemoveLeaf

func (n *LayoutNode) RemoveLeaf(paneID string) bool

RemoveLeaf removes the leaf with paneID from the tree by promoting the sibling to the parent's position. Returns false if paneID is the sole root leaf or is not found.

func (*LayoutNode) SplitLeaf

func (n *LayoutNode) SplitLeaf(paneID string, dir SplitDir) *LayoutNode

SplitLeaf replaces the leaf with paneID with an internal node. The existing pane becomes the Left child; a placeholder leaf (nil Pane) is created as the Right child and returned so the caller can fill it. Returns nil if paneID is not found.

type Model

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

func NewModel

func NewModel(client Client, cfg config.Config, version string, registry *plugin.Registry, stalePlugins []plugin.StalePlugin) Model

NewModel builds the client.

The first parameter is the Client INTERFACE rather than *ipc.Client so a *Router can be passed — the router is what multiplexes several daemons behind the single client the Model consumes, and it must be constructed before the Model rather than installed afterwards: tea.NewProgram takes the Model BY VALUE, so anything that reads back through a closure over main's copy would be frozen at startup.

func (Model) ApplyUpdateRequested added in v1.37.0

func (m Model) ApplyUpdateRequested() bool

ApplyUpdateRequested reports whether the user confirmed applying the staged update; main.go acts on it after the program exits.

func (Model) CloseClient added in v1.45.0

func (m Model) CloseClient()

CloseClient releases every connection the Model currently holds. Called by cmd/quil on exit, after the Bubble Tea program has returned.

A router is unwrapped rather than handed over whole, and that is the fix for a real leak rather than a tidiness: closeClientFn type-asserts to *ipc.Client, so passing the *Router simply missed and exit closed NOTHING — every ssh child and every remote `quil --stdio` outlived the client, on top of the per-reconnect leak retire used to cause. cmd/quil's own `defer client.Close()` cannot cover this either: it captured the startup conn of ONE destination.

func (Model) Config

func (m Model) Config() config.Config

Config returns the current config (may be modified by user actions).

func (Model) ConfigChanged

func (m Model) ConfigChanged() bool

ConfigChanged reports whether the config was modified and needs saving.

func (Model) FlushNotes

func (m Model) FlushNotes()

FlushNotes writes any pending notes edits to disk. Safe to call when notes mode is inactive (no-op).

Precondition: must be invoked AFTER tea.Program.Run has returned, when the Update goroutine is no longer pumping events. Calling concurrently with the Update loop is unsafe — the editor is mutable shared state.

func (Model) Init

func (m Model) Init() tea.Cmd

func (*Model) RemoteMode added in v1.43.0

func (m *Model) RemoteMode() bool

RemoteMode reports whether the daemon behind the ACTIVE project lives on another host.

The active project's Dest is now the WHOLE answer. It used to be the union of that and a session-wide remoteDest field, because `quil --remote <host>` routed everything unstamped and stamped no project — so activeDest() read "" for a session that was entirely remote. That union had a known expiry, and this is it: once a client can hold a local daemon beside a remote one, a live session-wide flag answers "remote" for a LOCAL project the user is looking at, which is the wrong answer for every caller — the update controls it suppresses are wired to local disk, and the plugin availability it swaps out describes the wrong machine. --remote now keys its own connection by host, so its project carries a Dest like any other and nothing is lost.

func (*Model) SetClientCloser added in v1.45.0

func (m *Model) SetClientCloser(f func(Client))

SetClientCloser installs the way to release a connection.

Needed because Client is deliberately only Send/Receive, so this package cannot close the ssh child behind one. Two callers need it: discarding a late-arriving reconnect, and releasing the LIVE connection on exit — which `cmd/quil`'s own `defer client.Close()` cannot do, because it captured the startup client and after a reconnect the live one only exists on the Model. This repo has already paid for leaked child processes on Windows once.

func (*Model) SetDialFunc added in v1.47.0

func (m *Model) SetDialFunc(f DialFunc)

SetDialFunc installs the runtime dialer. A Model without one simply cannot connect new hosts — every test Model, and any future caller that has no ssh transport, keeps working with the destinations it was built with.

func (*Model) SetInstallFunc added in v1.47.0

func (m *Model) SetInstallFunc(f InstallFunc)

SetInstallFunc installs the provisioner. A Model without one reports the missing binary and names the CLI instead of offering.

func (*Model) SetRecentCWDs added in v1.46.0

func (m *Model) SetRecentCWDs(list []string)

SetRecentCWDs replaces the remembered working-directory list.

Exists because the list is scoped per remote destination while NewModel — which runs before the destination is known — can only load the local one. Kept as an explicit setter rather than a side effect inside SetRemoteDest: that setter is called from ~46 tests which build a Model directly and never set QUIL_HOME, and a disk read there would point every one of them at the developer's real ~/.quil.

func (*Model) SetRedialFactory added in v1.47.0

func (m *Model) SetRedialFactory(f func(dest string) RedialFunc)

SetRedialFactory installs the builder for a newly connected destination's reconnect ladder. SetRedialFunc still handles the launch-time destinations one at a time; this covers the ones that did not exist yet.

func (*Model) SetRedialFunc added in v1.45.0

func (m *Model) SetRedialFunc(dest string, f RedialFunc)

SetRedialFunc installs the reconnect dialer for ONE destination. Called by cmd/quil in remote mode only; a destination with no func never reconnects, which is what local sessions get.

dest is the ROUTING destination, not the ssh host name. A single-connection remote session routes everything unstamped, so its key is "" — the same key its link loss, its projects and its freeze all carry. Only a client holding several daemons keys by host.

A setter rather than a NewModel parameter for the same reason SetRemoteDest is one: NewModel's signature is already at five arguments.

func (Model) StopInputForwarder added in v1.47.2

func (m Model) StopInputForwarder()

StopInputForwarder stops inputForwarder and WAITS for it to finish draining.

The wait is the point. Closing inputDone only asks the forwarder to drain; the caller then closes the IPC client, and a connection closed mid-drain discards whatever had not yet been written — the same lost keystrokes, one layer further down. Blocking here is safe because it runs after tea.Program.Run returns: the Update goroutine is gone, so nothing can add to the queue and the drain is bounded by what is already in it.

Safe to call once. No-op when the channels were never created (tests that construct Model literally). Wired from main.go's TUI-exit path, ahead of the client close.

func (Model) Update

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

func (Model) View

func (m Model) View() tea.View

func (Model) WindowSize

func (m Model) WindowSize() (width, height int)

WindowSize returns the last known window dimensions for persistence.

type NotesEditor

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

NotesEditor is a plain-text notes editor bound to a pane. It wraps the generic TextEditor and provides its own save path (bypassing TextEditor's TOML validation) and a debounced auto-save policy.

func NewNotesEditor

func NewNotesEditor(notesDir, paneID, paneName string, viewW, viewH int) (*NotesEditor, error)

NewNotesEditor loads the notes file for paneID (creating the editor even when the file does not yet exist) and returns an editor positioned at the top of the document.

func (*NotesEditor) ApproxBytes added in v1.9.0

func (n *NotesEditor) ApproxBytes() uint64

ApproxBytes returns a lower-bound in-memory byte count for the editor buffer. Used by the Memory dialog to attribute notes memory per pane.

func (*NotesEditor) BeginSelection

func (n *NotesEditor) BeginSelection(row, col int)

BeginSelection starts a fresh selection anchored at (row, col) and places the cursor there. Subsequent ExtendSelection calls grow the selection from this anchor.

func (*NotesEditor) ClearSelection

func (n *NotesEditor) ClearSelection()

ClearSelection discards any active selection without moving the cursor.

func (*NotesEditor) Close

func (n *NotesEditor) Close() error

Close flushes pending unsaved changes to disk and returns any save error.

func (*NotesEditor) Content

func (n *NotesEditor) Content() string

Content returns the current editor buffer as a single string.

func (*NotesEditor) Dirty

func (n *NotesEditor) Dirty() bool

Dirty reports whether there are unsaved edits.

func (*NotesEditor) ExtendSelection

func (n *NotesEditor) ExtendSelection(row, col int)

ExtendSelection moves the selection's cursor end to (row, col), keeping the anchor fixed. Used during mouse drag.

func (*NotesEditor) ExtractSelection

func (n *NotesEditor) ExtractSelection() string

ExtractSelection returns the currently selected editor text, or "" if no selection is active.

func (*NotesEditor) HandleKey

func (n *NotesEditor) HandleKey(key string) (notesAction, tea.Cmd)

HandleKey processes a key press. Returns:

  • action: what the outer model should do (`notesActionNone` to keep editing, `notesActionExit` to leave notes mode)
  • cmd: an optional tea command (e.g., for async paste)

ctrl+s and esc are intercepted before being passed to the TextEditor so the editor's TOML-specific Save() and its close-on-esc behaviour do not fire.

func (*NotesEditor) HandlePaste

func (n *NotesEditor) HandlePaste(text string)

HandlePaste applies pasted content at the cursor position.

func (*NotesEditor) HasSelection

func (n *NotesEditor) HasSelection() bool

HasSelection reports whether a non-empty selection is currently active in the notes editor. Used by mouse handlers to decide whether a right-click should copy editor text or fall through to the pane path.

func (*NotesEditor) MaybeAutoSave

func (n *NotesEditor) MaybeAutoSave()

MaybeAutoSave saves when the debounce window has elapsed since the last edit. No-op if the editor is clean or the user is still actively editing.

func (*NotesEditor) PaneID

func (n *NotesEditor) PaneID() string

PaneID returns the pane this editor is bound to.

func (*NotesEditor) Resize

func (n *NotesEditor) Resize(w, h int)

Resize updates the editor's viewport dimensions.

func (*NotesEditor) Save

func (n *NotesEditor) Save() error

Save writes the current content to disk and clears the dirty flag on both the wrapper and the inner TextEditor. Safe to call when not dirty — it is a no-op in that case. Ensures the saved file ends with a newline so it behaves like a normal POSIX text file.

func (*NotesEditor) SaveErr

func (n *NotesEditor) SaveErr() string

SaveErr returns the most recent save error, if any.

func (*NotesEditor) SetCursor

func (n *NotesEditor) SetCursor(row, col int)

SetCursor moves the editor's cursor to (row, col) in the document and clears any active selection. Used by mouse-driven cursor positioning. Coordinates are clamped to valid line/column bounds.

func (*NotesEditor) View

func (n *NotesEditor) View(width, height int, focused bool) string

View renders the notes editor inside a bordered box of the given size. The box includes a header with the pane name + dirty indicator and a footer with quick-reference hints. The focused parameter controls border colour: bright when the editor has keyboard focus, dim when the bound pane has focus (set via Tab in the surrounding model).

type NotificationCenter

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

NotificationCenter manages the notification sidebar state.

func NewNotificationCenter

func NewNotificationCenter(width, maxEvents int) *NotificationCenter

NewNotificationCenter creates a notification center with the given sidebar width and max events.

func (*NotificationCenter) AddEvent

func (nc *NotificationCenter) AddEvent(e ipc.PaneEventPayload)

AddEvent prepends an event. When an event with the same ID is already queued, the entry is updated in place AND moved to the front — this is the echo of the daemon's eventQueue.Push aggregation, where a repeat (PaneID, Title) event reuses the prior event's ID and bumps Data["count"]. Without the move-to-front the sidebar would silently drop bumps and the user would never see the ×N count grow.

Cursor invariant: the cursor follows the LOGICAL event the user is on, not the index. If the move-to-front shifts other events past the cursor's position, we rewrite cursor to point at the event with the same ID it had before — so a user staring at "claude-code (×3)" does not silently jump to a different card when "claude-code (×4)" arrives.

func (*NotificationCenter) Count

func (nc *NotificationCenter) Count() int

Count returns the number of pending events.

func (*NotificationCenter) DismissAll

func (nc *NotificationCenter) DismissAll()

DismissAll removes all events.

func (*NotificationCenter) DismissSelected

func (nc *NotificationCenter) DismissSelected() string

DismissSelected removes the selected event and returns its ID.

func (*NotificationCenter) HandleKey

func (nc *NotificationCenter) HandleKey(key string) (action, eventID, paneID string)

HandleKey processes a key press when the sidebar is focused. Returns: action ("navigate", "dismiss", "dismiss_all", "unfocus", "none"), eventID (for dismiss), paneID (for navigate).

func (*NotificationCenter) SelectedEvent

func (nc *NotificationCenter) SelectedEvent() *ipc.PaneEventPayload

SelectedEvent returns the currently selected event, or nil.

func (*NotificationCenter) View

func (nc *NotificationCenter) View(height int) string

View renders the sidebar at the given height.

type PaneInfo

type PaneInfo struct {
	ID           string
	TabID        string
	CWD          string
	Name         string
	Type         string
	Muted        bool
	Eager        bool
	Overlay      bool
	Pending      bool // deferred restore — not yet lazy-spawned
	SessionID    string
	HistoryLines int
	// MouseTracking/MouseSGR are daemon-authoritative (scanned from the PTY
	// stream): the child app has enabled mouse tracking, so wheel events
	// should be forwarded to it. Mirrored onto PaneModel for the wheel handler.
	MouseTracking bool
	MouseSGR      bool
	// BracketedPaste is daemon-authoritative (scanned from the PTY stream):
	// the child app has enabled bracketed paste (?2004), so pasted text should
	// be wrapped in \x1b[200~/\x1b[201~ markers. Mirrored onto PaneModel for
	// the paste paths.
	BracketedPaste bool
	// Git state is daemon-authoritative and broadcast-only: the daemon holds
	// the disk the repository lives on, which is the whole point when that
	// daemon is on another machine. GitUpstream distinguishes "in sync" from
	// "nothing to compare against" — without it, 0/0 would claim the first
	// when it means the second.
	// SpawnError explains why a pane has no process — today, a worktree-owned
	// pane whose directory is gone. Daemon-authoritative and runtime-only.
	SpawnError  string
	GitBranch   string
	GitDetached bool
	GitWorktree bool
	// GitWorktreeName names the linked worktree the pane's CWD is in. Derived
	// daemon-side: path separators belong to the machine holding the disk, so
	// a Windows daemon's path split by a Linux client's filepath.Base returns
	// the whole string.
	GitWorktreeName string
	GitUpstream     bool
	GitAhead        int
	GitBehind       int
	GitStale        bool
	// Model/ContextTokens are daemon-authoritative (extracted from hook event
	// data at turn boundaries): the model id and context-window token count of
	// the pane's last completed AI turn. Empty/zero for non-AI panes.
	Model         string
	ContextTokens int64
}

type PaneModel

type PaneModel struct {
	ID            string
	Type          string // plugin type ("terminal", "claude-code", etc.)
	WideCanvas    bool   // [display] wide_canvas: VT/PTY stay window-sized; small rects render a wrapped preview
	MinNativeCols int    // [display] min_native_cols: inner-width threshold for native (non-canvas) rendering; 0 = default 80
	Name          string // user-given name (empty if not set)
	CWD           string // current working directory from daemon
	Muted         bool   // notification mute (daemon-authoritative; mirrored here for border rendering)
	Eager         bool   // eager-restore flag (daemon-authoritative; mirrored for the tab marker)

	Width  int
	Height int
	// NativeW is Width plus whatever the project sidebar reserved — the
	// width this rect would have with the sidebar closed. It decides the
	// pane's render mode and nothing else (paneVTSize), so toggling the
	// sidebar changes how much of a pane you see, never how it renders.
	// Written by the resize recursion so resizeAllPanes computes the same
	// wire size the VT already took, rather than re-deriving it and drifting.
	NativeW int
	Active  bool

	Pending      bool   // deferred restore — not yet lazy-spawned (daemon-authoritative)
	SessionID    string // tracked session id (daemon-authoritative; restore checklist)
	HistoryLines int    // ghost-buffer line count (daemon-authoritative; restore checklist)
	// Git state of the pane's CWD, daemon-authoritative (see PaneInfo).
	// GitStale means the last refresh did not complete, so these are the last
	// values actually observed rather than current ones.
	// SpawnError explains why this pane has no process; empty when it has one.
	// Rendered in the pane's own rectangle in place of VT content.
	SpawnError      string
	GitBranch       string
	GitDetached     bool
	GitWorktree     bool
	GitWorktreeName string
	GitUpstream     bool
	GitAhead        int
	GitBehind       int
	GitStale        bool
	Model           string // model id of the last completed AI turn (daemon-authoritative; status bar)
	ContextTokens   int64  // context-window tokens of the last completed AI turn (daemon-authoritative; status bar)
	// contains filtered or unexported fields
}

func NewPaneModel

func NewPaneModel(id string, bufSize int) *PaneModel

func (*PaneModel) AppendOutput

func (p *PaneModel) AppendOutput(data []byte)

func (*PaneModel) BracketedPasteEnabled added in v1.45.2

func (p *PaneModel) BracketedPasteEnabled() bool

BracketedPasteEnabled reports whether the pane's child app has enabled bracketed paste (?2004) — i.e. pasted text should be wrapped in \x1b[200~/\x1b[201~ markers. Apps that never enabled the mode must receive pastes as raw bytes: injecting markers they didn't ask for corrupts their stdin (e.g. `cat > file` writes the escape bytes into the file).

Deliberately NOT the `local || daemon` shape MouseTracking uses. The daemon flag rides the workspace snapshot, which is throttled by the mode-broadcast cooldown, so it lags a disable by up to that window. OR-ing the two would keep wrapping pastes for an app that has just turned the mode off — and for this mode the cost is escape bytes injected into the app's stdin, the exact corruption the gate exists to prevent, rather than MouseTracking's cosmetic stray wheel notch. So the local emulator wins once it has actually seen a toggle for this pane; the daemon flag covers only the case the local emulator cannot answer — reattaching to an app that announced the mode before this client connected.

func (*PaneModel) Dispose added in v1.18.6

func (p *PaneModel) Dispose()

Dispose closes the VT emulator, stopping its drainVTResponses goroutine and releasing the scrollback grid. Must be called for every PaneModel removed from the layout tree — without it each closed pane leaks a parked goroutine plus up to a 10,000-line scrollback. The PaneModel must not be rendered or written to afterwards. Idempotent: a second call is a no-op.

func (*PaneModel) MouseTracking added in v1.32.0

func (p *PaneModel) MouseTracking() bool

MouseTracking reports whether the pane's child app has enabled any mouse tracking mode — i.e. it wants to handle mouse events (wheel scroll, clicks) itself rather than letting Quil scroll its local scrollback. Combines the local emulator state (fast path for freshly-created panes whose mouse-enable burst we just saw) with the daemon-authoritative flag (the reliable path on reattach, where the burst was emitted before this client connected).

func (*PaneModel) ResetScroll

func (p *PaneModel) ResetScroll()

func (*PaneModel) ResetVT

func (p *PaneModel) ResetVT()

ResetVT creates a fresh VT emulator at the current dimensions, clearing ghost buffer state so live output starts with a clean cursor position.

func (*PaneModel) ResizeVT

func (p *PaneModel) ResizeVT(cols, rows int)

func (*PaneModel) ScrollDown

func (p *PaneModel) ScrollDown(lines int)

func (*PaneModel) ScrollToRelY added in v1.15.0

func (p *PaneModel) ScrollToRelY(relY, innerH int)

ScrollToRelY positions the scrollback so that the scrollbar thumb's TOP row lands at relY (relative to the content area, 0..innerH-1). Inverse of the thumb-position formula in renderScrollback — a click at row R puts the thumb's top at R, matching standard GUI scrollbar UX.

CONTRACT (must stay in sync with renderScrollback):

renderScrollback:  thumbSize = max(1, h*h/totalLines)
                   thumbPos  = viewStart * (h - thumbSize) / scrollRange
                              where scrollRange = totalLines - h = sbLen
this fn (inverse): viewStart = relY * sbLen / (innerH - thumbSize)

Drift between the two is a silent UX bug. The integer math is safe on every supported quil platform (Go int is 64-bit on amd64 and arm64); even a million-line scrollback with a thousand-row pane multiplies to well under 2^63.

Out-of-range relY clamps to the valid scroll extent. Returns silently (no-op) when there's no scrollback to scroll into or the visible area is large enough to hold every line (no scrollable range).

func (*PaneModel) ScrollUp

func (p *PaneModel) ScrollUp(lines int)

func (*PaneModel) View

func (p *PaneModel) View() string

type PaneOutputMsg

type PaneOutputMsg struct {
	PaneID string
	Data   []byte
	Ghost  bool
}

Messages from daemon

type PaneRect

type PaneRect struct {
	Pane         *PaneModel
	OX, OY, W, H int
}

PaneRect holds a pane and its screen-space rectangle.

type PaneRef

type PaneRef struct {
	ProjectID string
	TabIndex  int
	PaneID    string
}

PaneRef stores a pane location for navigation history. ProjectID names the project the location was recorded under — a bare TabIndex is only meaningful relative to ITS OWN project's tab list, so restoring one without first resolving the project it belongs to can reinterpret it against whichever project happens to be active at pop time.

type PluginErrorMsg

type PluginErrorMsg struct {
	PaneID  string
	Title   string
	Message string
}

PluginErrorMsg is received when the daemon detects a plugin error pattern.

type ProjectInfo added in v1.47.0

type ProjectInfo struct {
	ID        string
	Name      string
	RootDir   string
	TabIDs    []string
	ActiveTab string
	// Bootstrap is the daemon saying it invented this project rather than a
	// user naming it — see daemon.Project.Bootstrap. It is what makes naming a
	// project on a fresh host adopt the host's tabs instead of leaving a
	// "Default" beside them.
	Bootstrap bool
}

ProjectInfo is one daemon-side project as broadcast. TabIDs carries the project's own tab ORDER — the tab bar renders it verbatim — and ActiveTab is the tab that project was last left on (the daemon keeps it in sync with the global active tab for the active project, so it needs no special casing).

type ProjectModel added in v1.47.0

type ProjectModel struct {
	ID      string
	Name    string
	RootDir string
	Dest    string
	// Bootstrap mirrors the daemon's flag: this project exists because a tab
	// needed a home, not because anyone named it. Naming a project on a host
	// whose only project is this one renames it in place.
	Bootstrap bool
	// contains filtered or unexported fields
}

ProjectModel is the client's view of one daemon-side project plus the destination it arrived on. Each project owns its OWN tab slice and its own activeTab index — nothing is ever filtered, so no index can be invalidated from under a caller.

Dest is client-side only: the daemon does not know it is remote. Empty means the local daemon.

type RedialFunc added in v1.45.0

type RedialFunc func(old Client) (Client, error)

RedialFunc dials a replacement connection after a drop.

The dead client is passed in so the caller can close it: Client is deliberately just Send/Receive, so the TUI has no way to release the underlying ssh child itself, and cmd/quil is the only layer that knows the value is really an *ipc.Client.

type Router added in v1.47.0

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

Router multiplexes several daemon connections behind the single tuiClient the Model consumes — a third implementation of that interface beside *ipc.Client and the test fakes, which is why the Model needs no transport change to gain multi-daemon support.

func NewRouter added in v1.47.0

func NewRouter(conns map[string]Client) *Router

NewRouter builds a router over the given connections, keyed by destination. The empty key is the local daemon.

func (*Router) Add added in v1.47.0

func (r *Router) Add(dest string, c Client)

Add installs a connection for dest and starts its pump.

LIVENESS IS KEYED OFF r.stop, NOT r.conns, and that is the whole reason a dead connection is still reachable through Conn. A second pump on the same LIVE conn would race two readers over one socket, so a live dest is left alone — but "live" has to mean "a pump is still running", not "a conn is still recorded". Retiring the conn to express deadness (the first shape of this) made Conn(dest) nil for exactly the destination a redial was about to run for, so the dialer was handed nothing to close and every reconnect leaked an ssh child plus its remote `quil --stdio`. Keying on the stop channel lets the record outlive the pump, which is what makes the close reachable.

func (*Router) Conn added in v1.47.0

func (r *Router) Conn(dest string) Client

Conn returns the connection installed for dest, or nil when none is.

The reconnect loop needs it to hand the DEAD connection to the dialer, which is the layer that can close it — so a conn whose pump has died stays reachable here until Add replaces it or Remove drops it. Nil is still a real answer for a destination that was never dialled (one unreachable at launch), and callers must tolerate it — see Model.connFor.

func (*Router) Conns added in v1.47.0

func (r *Router) Conns() []Client

Conns snapshots the connections themselves, for the ONE caller that has to release them: Model.CloseClient on exit.

Returning the values rather than exposing the map keeps the release loop outside the lock — closing an ssh-backed conn reaps a child process, which must not run under a mutex the pumps also take.

func (*Router) Dests added in v1.47.0

func (r *Router) Dests() []string

Dests lists every destination the router holds a connection for, live or retired. It is what the Model attaches over: a destination with no conn has nothing to attach to, and one whose pump died is re-attached by the reconnect path rather than by an attach sweep.

Order is map order, i.e. unspecified. Every caller either attaches to all of them or closes all of them, so no caller may depend on it.

func (*Router) Receive added in v1.47.0

func (r *Router) Receive() (*ipc.Message, error)

Receive hands the Model the next message from any connection. It never returns an error: a dead connection arrives as MsgLinkLost carrying the dest that died, so one daemon's loss cannot be read as the session's.

func (*Router) Remove added in v1.47.0

func (r *Router) Remove(dest string)

Remove drops a connection from the routing table and signals its pump.

It cannot interrupt a pump parked inside Receive — Client is deliberately only Send/Receive, so this package cannot close the socket underneath one; that is what SetClientCloser exists for. What the stop channel guarantees is narrower and is checked FIRST at every publish point: a Remove that completes before the parked read returns can never deliver another message or a link loss for a dest the caller deliberately disconnected. (Without that ordering the guarantee is a coin flip — select chooses uniformly among ready cases, and a 64-buffered r.in is almost always ready.)

func (*Router) Send added in v1.47.0

func (r *Router) Send(m *ipc.Message) error

Send routes on the stamp. An UNSTAMPED message resolves to the active project's dest — NOT to local — so a missed stamp fails toward the daemon the user is looking at. During startup there are no projects yet, so a single-connection client falls back to its sole conn; that keeps remote-only mode, where no "" conn exists, from dropping its own first sends. Both the active-dest resolution and that fallback are restricted to unstamped messages: a send that named a destination named it for a reason, and re-aiming it at whatever conn happens to be the only one is how a remote pane's keystrokes end up on the local daemon — or a local pane's on a remote host.

Nothing on the startup path depends on that fallback any more, and it must stay that way: it is gated on len(r.conns) == 1, so a router holding a local daemon beside a remote one silently delivered the deliberately-unstamped startup attach to conns[""] alone — the remote was never attached, sent no workspace state, and contributed no projects, with no error anywhere. The attach is per-destination and stamped now (Model.attachAllDests), and so is requestPluginList. What remains is a one-conn safety net for the window between launch and the first workspace_state, where activeDest() is still "" because there are no projects to read it from.

func (*Router) SetActiveDest added in v1.47.0

func (r *Router) SetActiveDest(dest string)

SetActiveDest is called by the running program whenever the active project changes, so the router's default routing target tracks what the user is looking at. Safe from any goroutine.

type SavedInstance

type SavedInstance struct {
	ID          string            `json:"id"`
	Name        string            `json:"name"`
	Fields      map[string]string `json:"fields"`
	Description string            `json:"description,omitempty"`
}

SavedInstance is a user-created instance of a plugin (e.g., an SSH connection).

func (SavedInstance) DisplayAddr

func (si SavedInstance) DisplayAddr() string

DisplayAddr formats a saved instance's fields into a short address string. Tries user@host:port, falls back to showing the first non-name, non-description field.

type Selection

type Selection struct {
	PaneID string
	Anchor SelectionAnchor // where selection started (fixed)
	Cursor SelectionAnchor // where selection extends to (moves with keys/mouse)
}

Selection tracks a text selection within a single pane.

func (*Selection) ColRange

func (s *Selection) ColRange(absLine, width int) (startCol, endCol int)

ColRange returns the selected column range for a given absolute line. Returns (-1, -1) if the line is not in the selection.

func (*Selection) Normalized

func (s *Selection) Normalized() (start, end SelectionAnchor)

Normalized returns start/end ordered top-to-bottom, left-to-right.

type SelectionAnchor

type SelectionAnchor struct {
	Col  int // 0-based column within pane content (excludes border)
	Line int // absolute line: 0..sbLen-1 = scrollback, sbLen..sbLen+h-1 = screen
}

SelectionAnchor identifies a cell in the combined scrollback+screen space.

type SerializedNode

type SerializedNode struct {
	PaneID string          `json:"pane_id,omitempty"`
	Split  *SplitDir       `json:"split,omitempty"`
	Ratio  float64         `json:"ratio,omitempty"`
	Left   *SerializedNode `json:"left,omitempty"`
	Right  *SerializedNode `json:"right,omitempty"`
}

SerializedNode is a JSON-friendly representation of a LayoutNode tree. Leaf nodes have PaneID set; internal nodes have Split, Ratio, Left, Right.

func SerializeLayout

func SerializeLayout(n *LayoutNode) *SerializedNode

SerializeLayout converts a LayoutNode tree into a SerializedNode tree.

func UnmarshalLayout

func UnmarshalLayout(data json.RawMessage) (*SerializedNode, error)

UnmarshalLayout deserializes JSON into a SerializedNode tree.

type SplitDir

type SplitDir int

SplitDir determines how child nodes are arranged.

const (
	SplitHorizontal SplitDir = iota // children side-by-side (left | right)
	SplitVertical                   // children stacked (top / bottom)
)

type TabInfo

type TabInfo struct {
	ID   string
	Name string
	// ProjectID is the owning project as the TAB records it. Project.TabIDs is
	// what a rebuild iterates; this is the tab's own answer to the same
	// question, used to reject a stale TabIDs entry that would otherwise build
	// one TabModel into two projects at once.
	ProjectID string
	Color     string
	Panes     []string
	Layout    json.RawMessage
}

type TabModel

type TabModel struct {
	ID   string
	Name string
	// Dest is the destination the tab's project arrived on — client-side
	// only, empty for the local daemon. Carried on the tab so a pane event
	// can be routed without walking back up to the project.
	Dest       string
	Color      string
	Root       *LayoutNode // binary split tree (nil = empty tab)
	ActivePane string      // pane ID of the active pane
	Width      int
	Height     int
	// CanvasW/CanvasH: full tab-area dimensions for wide-canvas panes
	// (set via SetCanvas before Resize; independent of notes squeeze).
	CanvasW int
	CanvasH int
	// ChromeW: columns the project sidebar reserved out of this tab's
	// width. Resize adds it back to derive each pane's sidebar-free width,
	// which decides render mode only (see resizeNode/paneVTSize). The notes
	// squeeze is deliberately NOT counted here — notes is a per-pane editor
	// the user opened against this pane, so shrinking it is the point;
	// the sidebar is session chrome and must not re-mode anything.
	ChromeW int
	// contains filtered or unexported fields
}

TabModel represents a single tab containing a tree of panes.

func NewTabModel

func NewTabModel(id, name string) *TabModel

func (*TabModel) ActivePaneModel

func (t *TabModel) ActivePaneModel() *PaneModel

ActivePaneModel returns the currently active pane, or nil. When the overlay is visible it acts as the single active pane — all input and scroll events route through this choke point. Unlike treeActivePaneModel, a stale ActivePane is REPAIRED here: the returned fallback leaf is adopted (t.ActivePane rewritten, Active flag set).

func (*TabModel) ExitFocus

func (t *TabModel) ExitFocus()

ExitFocus exits focus mode if active.

func (*TabModel) FocusMode

func (t *TabModel) FocusMode() bool

FocusMode returns whether focus mode is active.

func (*TabModel) Leaves added in v1.18.6

func (t *TabModel) Leaves() []*PaneModel

Leaves returns the tab's panes in layout order, cached until the tree mutates. Every method that mutates the tree (or assigns Root) must call invalidateLeaves.

func (*TabModel) NavigateDirection added in v1.3.0

func (t *TabModel) NavigateDirection(dir Direction) bool

NavigateDirection moves focus to the closest pane in the given direction, if any exists. Returns true when focus changed. Semantics mirror tmux's `select-pane -L/R/U/D` and vim's window-motion commands: candidates must lie strictly in the half-plane on the target side and must overlap the active pane's perpendicular range. Tie-breakers are applied in order:

  1. smallest gap along the direction axis,
  2. largest perpendicular overlap,
  3. smallest perpendicular center-to-center distance (tmux/vim parity — when two equally-close candidates have the same overlap, the one whose center is closer to the active pane's center on the perpendicular axis wins, matching the user's muscle memory).

Does nothing in focus mode or when the tab is empty.

func (*TabModel) NextPane

func (t *TabModel) NextPane()

NextPane advances focus to the next pane (in-order traversal order).

func (*TabModel) PrevPane

func (t *TabModel) PrevPane()

PrevPane moves focus to the previous pane.

func (*TabModel) RemovePane

func (t *TabModel) RemovePane(paneID string)

RemovePane removes the pane with the given ID, promoting its sibling. If the removed pane was active, focus moves to the first leaf.

func (*TabModel) Resize

func (t *TabModel) Resize(w, h int)

Resize recomputes dimensions for the entire layout tree.

func (*TabModel) SetCanvas added in v1.33.1

func (t *TabModel) SetCanvas(w, h int)

SetCanvas records the full tab-area dimensions used to size wide-canvas panes. Callers (resizeTabs, View) set it BEFORE Resize so the canvas is independent of the notes-panel squeeze: canvas = (window width, tab height). A zero canvas makes paneVTSize fall back to rect sizing.

func (*TabModel) SetChrome added in v1.47.0

func (t *TabModel) SetChrome(w int)

SetChrome records how many columns the project sidebar took out of the width that follows in Resize. Set beside SetCanvas, and zero is the right answer everywhere else (tests, the overlay's own full-tab sizing) — it means "this width already is the sidebar-free one".

func (*TabModel) SplitAtPane

func (t *TabModel) SplitAtPane(paneID string, dir SplitDir) *LayoutNode

SplitAtPane splits the pane with the given ID, inserting a placeholder for the new pane. Returns the placeholder node (caller fills Pane later).

func (*TabModel) ToggleFocus

func (t *TabModel) ToggleFocus()

ToggleFocus toggles pane focus mode on/off. No-op on single-pane tabs (already fills the tab).

func (*TabModel) View

func (t *TabModel) View() string

View renders the entire pane layout.

type TextEditor

type TextEditor struct {
	Lines      []string
	CursorRow  int // rune-based row
	CursorCol  int // rune-based column
	ScrollTop  int
	ViewHeight int
	ViewWidth  int
	FilePath   string
	Dirty      bool
	SaveErr    string
	Sel        *EditorSel // active selection (nil = none)
	// Highlight selects the syntax highlighter. Defaults to HighlightTOML.
	Highlight HighlightMode
	// ReadOnly disables every key path that would mutate the document
	// (typing, paste, cut, save, enter/backspace/delete, tab/space). Cursor
	// movement, selection, and clipboard COPY (Enter on a selection,
	// right-click) still work. Used by the F1 → log viewers so users can
	// scroll and copy log content without accidentally overwriting the
	// underlying file with Ctrl+S.
	ReadOnly bool
	// PageSize is the cursor jump distance for Alt+Up / Alt+Down. 0 falls
	// back to a built-in default (see editorDefaultPageSize). Used by the
	// log viewer to navigate large files quickly without holding Down.
	PageSize int
	// SoftWrap makes long logical lines wrap onto the next visual row
	// instead of being hard-truncated with a trailing "~". When enabled,
	// ScrollTop is a visual-row index and cursor Up/Down/Home/End work on
	// visual rows. Paragraph jumps (ctrl+up/down) and PageSize jumps
	// (alt+up/down) remain logical-line based. Only NotesEditor opts in
	// — the TOML plugin editor and F1 log viewer keep truncation.
	SoftWrap bool
}

TextEditor is a minimal multi-line text editor with optional syntax highlighting.

func NewTextEditor

func NewTextEditor(content, filePath string, viewW, viewH int) *TextEditor

NewTextEditor creates an editor from file content.

func (*TextEditor) ApproxBytes added in v1.9.0

func (e *TextEditor) ApproxBytes() uint64

ApproxBytes returns a lower-bound estimate of the editor's in-memory size. Sums UTF-8 byte lengths of all lines plus one newline byte per line boundary. Does not account for Go slice overhead or unused capacity. Used by the Memory dialog for ranking; precision is not important.

func (*TextEditor) Content

func (e *TextEditor) Content() string

Content returns raw text (no ANSI codes) for saving.

func (*TextEditor) GutterWidth

func (e *TextEditor) GutterWidth() int

GutterWidth returns the visible width (in columns) of the line-number gutter for the current document. It is `max(3, digits(len(Lines))) + 1` — three digits minimum plus one trailing space. Both Render() and the mouse-to-document coordinate helper (notesEditorPosAt) use this so the body content's left edge stays in sync with what the user sees.

func (*TextEditor) HandleKey

func (e *TextEditor) HandleKey(key string) (saved, closed bool, cmd tea.Cmd)

func (*TextEditor) InsertMultiLine

func (e *TextEditor) InsertMultiLine(text string)

InsertMultiLine inserts text that may contain newlines at the cursor position. If a selection is active, it is deleted first. No-op when ReadOnly.

func (*TextEditor) Render

func (e *TextEditor) Render() string

func (*TextEditor) Save

func (e *TextEditor) Save() error

Save validates TOML syntax and writes to disk atomically.

type WorkspaceStateMsg

type WorkspaceStateMsg struct {
	ActiveTab string
	Tabs      []TabInfo
	Panes     []PaneInfo
	// Projects is the sending daemon's project grouping, in its own order;
	// ActiveProject is the project that daemon considers current. A broadcast
	// is the FULL state of ONE daemon, so both describe that daemon only.
	Projects      []ProjectInfo
	ActiveProject string
	// Dest is the destination this broadcast arrived on — client-side only,
	// empty for the local daemon. It has to ride the message because
	// listenForMessages returns the parsed state directly as the tea.Msg, so
	// Update, not the parse site, is where applyWorkspaceState is called.
	Dest string
	// Update is the daemon's announced newer release (nil when up to date).
	Update *ipc.UpdateInfo
}

Jump to

Keyboard shortcuts

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