dashkit

module
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: MIT

README

dashkit

Shared Go helper packages for canopy (agent-session dashboard) and understory (git-worktree dashboard) — the two terminal dashboards that grow this kit. Both are Bubble Tea apps built around a bubbles/table, both need the exact same handful of things from it (mouse column-resize, row coloring/highlighting, open-or-focus-a-window on Enter, the destructive- action confirmation modal), so those live here once instead of being written twice and quietly drifting apart in each tree.

CONVENTIONS.md states the keybinding, confirmation-modal, phrasing, rendering, testing, and releasing conventions all three repos follow: the decisions behind the packages below, written down once.

Each package below is self-contained and independently importable:

import (
	"github.com/luiul/dashkit/trellis"
	"github.com/luiul/dashkit/loam"
	"github.com/luiul/dashkit/mycelium"
	"github.com/luiul/dashkit/confirm"
)

trellis — mouse column-resize

A tiny Go library for resizing a bubbles/table view's columns with the mouse: click and drag a column's border to widen or narrow it, the same way a spreadsheet or file manager does.

A trellis, in the garden sense, is the lattice of fixed bars a climbing plant is trained against — a structure whose whole point is that its grid can be adjusted without disturbing what grows on it. That's this package's one job too: rearrange a table's column widths in place, in response to a mouse drag, without needing to know anything about what a caller actually put in those columns.

What it does
  • Model + Handle — tracks one in-progress drag at a time. Feed it every tea.MouseMsg your Update receives; it tells you the resulting column widths and whether they changed. A drag can only start on the table's own header row (the same row a spreadsheet gives you a resize handle on), but once started keeps tracking the mouse regardless of which row it wanders into afterward — exactly like dragging a real window border.
  • Every border behaves the same way — drag it, and the two columns it sits between trade width between themselves; nothing else moves, so the table's total width never changes no matter which border is dragged. There's no dedicated "flex" column that silently absorbs every other column's drag — an earlier version of this package worked that way, but it meant one column's own border stopped responding to a drag while every other border secretly resized that one distant column instead of its actual neighbor. Which column (if any) fills whatever's left over after a terminal resize is a separate policy entirely, applied by the caller outside Handle (see each dashboard's own resizeColumns/worktreeColumns).
  • mins — a floor per column below which a drag won't shrink it, so dragging never truncates a column's own title or its shortest realistic content out of legibility.
  • Apply — a small helper that copies a []table.Column with new widths spliced in, for the table.SetColumns call right after a Handle that reported a change.
Usage
resizer := trellis.New()   // in your Model's own zero-value construction

// In Update, alongside your other tea.Msg cases:
case tea.MouseMsg:
    cols := m.table.Columns()
    mins := []int{6, 6, 8, 8, 9, 20} // one per column, in the same order
    _, originY := m.renderHeader()   // however many lines precede the table
    if widths, changed := m.resizer.Handle(msg, cols, mins, 0, originY); changed {
        m.table.SetColumns(trellis.Apply(cols, widths))
    }
    return m, nil

Mouse events only reach Update once the program itself is built with mouse support enabled:

p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())

A resized column's width is a live UI decision, not model data: nothing here persists it to disk. If your own dashboard rebuilds columns from scratch on every poll or terminal resize (as both canopy and understory do — content-driven column widths, one column recomputed from whatever's left of the terminal width), keep the user's last resize around yourself (e.g. a map[int]int of column index → override width, in your own Model, recording every index in the widths Handle returned — a drag always changes two of them at once, not only the one at DragColumn()— see its own doc) and reapply it each time you rebuild columns, the same way both dashboards' own README documents.

Users still need some way to see where a border actually is before they can grab it — see loam's DrawHeaderBorders below, which marks each of these same border positions with a visible divider on the table's header row.

loam — row/column coloring and highlighting

A tiny Go library for coloring and highlighting rows in a bubbles/table view by post-processing its already-rendered text, rather than putting ANSI-styled strings into table.Row values directly.

Why post-process instead of styling table.Row directly

bubbles/table v1's cell truncation (runewidth.Truncate) is not ANSI-aware: escape codes get counted as extra visible width and sliced mid-sequence, corrupting the row (verified empirically against bubbles/table v1.0.0 — a styled "unmerged" in a 9-wide column gets truncated with a dangling escape code). Post-processing the table's already-rendered plain-text view instead sidesteps that entirely: the widths/padding/truncation the table computes are always over plain text, and only the final display string gets colored.

What it does
  • WordColumn + ColorizeRows — recolor one or more columns of an already-rendered view, each cell picking its style from its own (trimmed) word. A WordColumn.Style can vary by word (e.g. "dirty" vs "clean"), always return the same style regardless of content (e.g. a Since/Updated column), or do its own pre-processing first (e.g. strip a trailing blink-marker suffix before deciding the style) — it's just a func(string) lipgloss.Style, so any of that is a caller-side closure, not something loam needs to know about.
  • Sentinel + Tag — mark whichever row should get a full-line highlight by prepending a zero-width Unicode tag to any one of its cells (Since/Updated-style columns are a good choice: always populated, never blanked for grouping, never truncated in practice). ColorizeRows finds that row from the rendered text — no need to track bubbles/table's internal scroll offset, which v1 doesn't expose anyway — highlights it, then strips the tag back out before returning, so it never reaches the terminal.
  • HighlightRow + StyleSequences — the primitive the row highlight is built on: wraps an entire line in a style even when that line already contains other ANSI (from RecolorWord, applied first). A naive open + line + close wrap breaks the moment line contains its own reset code, since every lipgloss render ends with a full SGR reset regardless of which attributes were opened — so HighlightRow reapplies its own opening sequence right after every such inner reset it finds, keeping the outer style in effect up to the real, final close.
  • ColumnOffsets + RecolorWord + DisplayColumnToByteOffset — the lower-level pieces: computing each column's start/width within a rendered line (accounting for bubbles/table's fixed padding), and recoloring one column's span by display column rather than byte offset, so a multi-byte rune in an earlier column (a truncation ellipsis, or a genuinely unicode name) never misaligns a later column's recoloring.
  • DrawHeaderBorders — marks each internal column border with a visible divider (BorderGlyph, "│") on the table's header row, so a user actually has something to aim a mouse drag at (see trellis above) instead of an invisible 2-space gap. Header-row-only, deliberately: a data row may already carry other ANSI from RecolorWord/HighlightRow (see the coloring hazard both of those already have to work around above), where the header line never does in either dashboard — so marking only the header sidesteps that hazard entirely rather than needing its own ANSI-aware column walk.
Usage
cols := []table.Column{
	{Title: "Updated", Width: 8},
	{Title: "Worktree", Width: 8},
	{Title: "Merge", Width: 9},
}

// Row building: tag the selected row via loam.Tag.
row := table.Row{
	loam.Tag(humanizeSince(e.CommitTime), i == cursor),
	worktreeStatusLabel(e),
	mergeStatusLabel(e),
}

// View: recolor Worktree/Merge, highlight whichever row is tagged, then
// mark the header's own column borders so there's something to drag.
view := loam.ColorizeRows(table.View(), table.Columns(), []loam.WordColumn{
	{Index: colWorktree, Style: worktreeStatusStyle},
	{Index: colMerge, Style: mergeStatusStyle},
}, rowHighlightStyle)
view = loam.DrawHeaderBorders(view, table.Columns(), subtleStyle)

mycelium — open-or-focus a window

A tiny Go library that opens, or focuses if one is already open, an app window (VS Code or a Ghostty terminal) on a given filesystem path — without ever risking a duplicate window for a path that's already open somewhere.

Why this needs to exist at all

code --reuse-window <path> alone isn't enough to get real switch-or-create behavior out of the code CLI: it only reuses the right window when one already has that exact folder open, and silently hijacks whichever window was last active otherwise, rather than opening a fresh one. Confirmed both empirically and in upstream reports (microsoft/vscode#121926, #216602, #215749).

mycelium.OpenVSCode checks for an already-open window itself first and only ever falls through to the CLI once that's ruled out — forcing a genuinely new window (-n) instead of handing --reuse-window a chance to guess wrong. Window identity comes from a per-window registry: every VS Code window self-registers into ~/.local/state/vscode-windows/ via the small vscode-window-registry extension, so matching is exact folder paths and focusing is a safe code --reuse-window. That makes it safe to call repeatedly on the same never-before-seen path: the already-open check finds the window OpenVSCode itself just created on every subsequent call, so nothing stacks up duplicate windows.

mycelium.OpenGhostty does the equivalent for a bare Ghostty tab, matching by working directory (Ghostty's tty/pid AppleScript properties don't reliably work as of Ghostty 1.3.1; working directory does).

mycelium.SnapshotVSCode is the read-only half: one queryable snapshot of which VS Code windows are open, for dashboards showing per-row window state (the "VS Code open?" columns) rather than acting on one selected row. Each IsOpen(path) runs the exact same match OpenVSCode does, so the column says "open" precisely when Enter would focus an existing window instead of opening a new one.

Usage
import "github.com/luiul/dashkit/mycelium"

result := mycelium.OpenVSCode("/Users/you/code/some-repo")
// result.OK, result.Message

result = mycelium.OpenGhostty("/Users/you/code/some-repo")

snapshot := mycelium.SnapshotVSCode() // one registry read per poll
open := snapshot.IsOpen("/Users/you/code/some-repo")

Both currently macOS-only: the VS Code side is portable (the extension and the reader are plain files), but Ghostty's window detection shells out to osascript, and there is no equivalent implementation for other platforms yet.

Errors

A failed AppleScript call (Ghostty) surfaces as *mycelium.AutomationError, or more specifically *mycelium.AutomationPermissionError when it looks like macOS's Automation permission for scripting the target app hasn't been granted yet (System Settings → Privacy & Security → Automation). Result.Message is already a human-readable rendering of either, meant to be shown straight to a user (e.g. as a TUI notification) — callers don't need to inspect the error types themselves unless they want to branch on them.

confirm — the destructive-action confirmation modal

The shared state machine behind both dashboards' confirmation prompts (understory's x/X/P/M worktree removal, canopy's x/X/D session signaling): one answer discipline, one auto-cancel timeout, one poll-revalidation hook, so the two modals cannot drift apart.

Unlike the other packages, the name is plainly descriptive rather than a garden metaphor: the package is exactly what it says.

What it does
  • Classify — the one answer set, in one place: y confirms; n, esc, or enter cancel (honoring the prompt's [y/N]); every other key is swallowed; ctrl+c quits, as it does from everywhere.
  • State[T] — the modal half of a prompt: the armed payload (the caller's own type: a worktree batch, a process list) plus the token its auto-cancel tick must match. Arm opens and schedules the tick, Resolve closes and invalidates it, Tick fires the timeout only for the prompt it was scheduled for.
  • Timeout + TimeoutText — an unanswered prompt cancels itself after 10s, with both apps showing the same notification text.
  • Refresh — the poll-revalidation hook: re-stamps an armed prompt's targets against each fresh poll, dropping the ones that vanished in the meantime, so a prompt never fires at rows that no longer mean what the user thought.

See confirm/README.md for the full discipline and a usage walkthrough.

Development

go build ./...
go vet ./...
go test ./...
gofmt -l .   # should print nothing

History

trellis, loam, and mycelium started as three separate repos, each imported independently by canopy and understory. They were merged into this one repo (with each package's original commit history preserved under its own subdirectory) once it became clear all three existed for the same single reason — shared dashboard behavior with exactly two consumers — and were paying triple the go.mod/go.sum/README/LICENSE/tag overhead for it. The three original repos are archived; see each for their pre-merge history. confirm joined later, born here, when the two dashboards' confirmation modals were aligned on one discipline (see understory#4) and the machinery was extracted so they cannot drift again.

License

MIT

Directories

Path Synopsis
Package confirm is the shared confirmation-modal state machine behind canopy's and understory's destructive-action prompts (x/X/P/M in understory, x/X/D in canopy): one answer discipline, one auto-cancel timeout, one poll-revalidation hook, so the two dashboards' modals cannot drift apart again.
Package confirm is the shared confirmation-modal state machine behind canopy's and understory's destructive-action prompts (x/X/P/M in understory, x/X/D in canopy): one answer discipline, one auto-cancel timeout, one poll-revalidation hook, so the two dashboards' modals cannot drift apart again.
Package loam is the shared rendering substrate canopy and understory both grow their bubbles/table dashboards from: per-word (or per-segment within a word) column coloring and whole-row selection highlighting, applied by post-processing an already-rendered table view rather than putting ANSI-styled strings into table.Row values directly.
Package loam is the shared rendering substrate canopy and understory both grow their bubbles/table dashboards from: per-word (or per-segment within a word) column coloring and whole-row selection highlighting, applied by post-processing an already-rendered table view rather than putting ANSI-styled strings into table.Row values directly.
Package mycelium opens, or focuses if one is already open, an app window (VS Code or a Ghostty terminal) on a given filesystem path, without ever risking a duplicate window for a path that's already open somewhere.
Package mycelium opens, or focuses if one is already open, an app window (VS Code or a Ghostty terminal) on a given filesystem path, without ever risking a duplicate window for a path that's already open somewhere.
Package trellis is the shared mouse-driven column-resize logic canopy and understory both grow their bubbles/table dashboards from: click and drag a column's border to widen or narrow it, the same way a spreadsheet or file manager lets you resize a column with the mouse.
Package trellis is the shared mouse-driven column-resize logic canopy and understory both grow their bubbles/table dashboards from: click and drag a column's border to widen or narrow it, the same way a spreadsheet or file manager lets you resize a column with the mouse.

Jump to

Keyboard shortcuts

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