Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
/docs/releases/*
!/docs/releases/v1.0.0.md
!/docs/releases/v1.0.1.md
!/docs/releases/v1.1.0.md

# compiled binary
/kb
Expand Down
132 changes: 132 additions & 0 deletions docs/releases/v1.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# kb v1.1.0

v1.1.0 is a terminal-interface redesign. The TUI carries a web-like visual
design language - Strata - across every surface it renders, and the frame it
renders into is the whole terminal. The command surface, the database format,
and the interaction model of v1.0.1 are unchanged: the same keys, the same
mouse targets, the same flows.

## The design language

- Depth is background shade, not borders. Every row of the frame carries a
shade tier to its edge - canvas behind the top bar and toolbars, surface
behind column panels and the footer, a card tier with zebra striping and a
raised tier for selection. The `┌ │ └` column frame and the rounded overlay
borders are gone; the tier step is the separation.
- Columns are headed by solid bands. An unfocused column sits on the raised
tier with its own hue as foreground, a half-block rail, and a status dot; a
focused column fills solid with that hue and the rail becomes a focus caret.
- Cards carry a priority rail that thickens on selection and keeps the
priority hue, a title with a never-truncated right-aligned `#seq`, a muted
description snippet, a meta chip row in survival order (priority, age,
blocked, due, effort), and a label pill row. Description on the card is the
first thing density compaction drops.
- Chips and labels are pill-capped surfaces from a five-color label wheel.
- Overlays separate from the board by elevation, not by outline: a shade step,
a solid brand header band, section break bands, a footer band, and a shadow
cast one cell down and right. The board behind an open overlay re-renders
through a dimmed palette rather than being painted over.
- Colors come from one place. `internal/tui/theme` holds a ~30-slot semantic
palette, authored in RGB with its xterm-256 quantization audited slot by
slot, and builds every style once per terminal background. No view
constructs a style; a seam test walks `internal/tui` and fails on any
`lipgloss.NewStyle` outside the theme package.

## Layout

- The board fills the frame. Columns split the whole terminal width instead of
clamping to 52 columns and centering, so a wide terminal becomes more board
rather than more margin.
- Content overlays are proportional: roughly 85% by 88% of a wide frame. The
card detail pane opens at 170x44 on a 200x50 terminal, where it used to open
at 72x13. Below 100 columns the v1.0.1 geometry is preserved.
- Compaction works on both axes. Crossing the height or column-width threshold
drops the description, the page padding row, the column meta line, the card
gutter, the inner paddings, and the pill end caps together, and neighbouring
columns always agree about density.

## Controls

- Actions are visible buttons. Card detail gains a pinned action row above its
footer band; the editor, the settings pane, the ship guard, and the kill and
purge prompts render padded button surfaces with underlined or parenthesized
hotkeys instead of bracketed text labels.
- Buttons carry semantic color. Neutral, primary, success, and danger each own
a hue, and the state - blurred, hovered, focused, armed - is the elevation
within it. Armed has its own deeper fill so a two-step confirmation can never
be mistaken for a focused danger button. Every variant and state pair holds
above 4.5:1 contrast in truecolor and after 256-color quantization.

## Charm components

The charm.sh ecosystem is now the source for every component it ships.

- Card detail body scrolling is `bubbles/v2 viewport`.
- The `?` overlay is a `bubbles/v2 key` registry rendered by `bubbles/v2 help`;
an unavailable feature is a disabled binding rather than an omitted line.
- Settings and forge-integration rows are laid out by `lipgloss/v2 table`.
- Every busy state that was static text carries the `bubbles/v2 spinner`:
reading a file, splitting an ADR, creating cards, fetching a preview, and -
new in this release - the card editor's drafting and saving states.
- The issue import's batch write carries a `bubbles/v2 progress` bar.
- `huh/v2` fields render the inline choice rows, the confirm prompt's yes-no
core, and the AI disclaimer notes. `huh`'s form container stays unadopted so
kb keeps its own focus model and mouse hit regions.

Hand-built widgets remain only where charm ships nothing: card surfaces,
column panels, label pills, chips, and standalone buttons.

## Text field fixes

- `Home` and `End` reach the focused field. Two pane steppers were claiming
keys the field owns: the card editor's due picker took `alt+left` and
`alt+right` (released; `[` and `]` keep the date stepper), and the issue
import input stage took `left`, `right`, `h`, and `l` for every focus - which
meant the reference field could not take a cursor motion, or the letters `h`
and `l` at all. While a text field has focus, a colliding pane shortcut now
yields to the field.
- `Ctrl+A` is select-all. Charm's fields have no selection model, so kb marks
the whole value: typing replaces it, `Backspace` clears it, navigation drops
the mark, and `Escape` drops it and is consumed. It works across all seven
text surfaces - editor, settings and integrations, board filter, kill reason,
ADR, forge import, and the detail comment and link fields.

## Upgrade from v1.0.1

No database migration is required. Existing tasks, comments, links,
tombstones, settings, encrypted credentials, and import provenance remain in
the same data directory. Keep `kb.db`, `kb.db-wal`, `kb.db-shm`, and `secret`
together when backing up or moving a board.

No keybinding, command, or flag changed. A v1.0.1 muscle memory is a v1.1.0
muscle memory.

## Install and verify

With Go 1.25.8 or newer:

```sh
go install github.com/RandomCodeSpace/kb@v1.1.0
kb version
```

The release contains five CGO-free binaries:

- Linux amd64 and arm64
- macOS amd64 and arm64
- Windows amd64

Download the matching binary and `SHA256SUMS` from the release, then verify it
before installation:

```sh
grep ' kb-linux-amd64$' SHA256SUMS | sha256sum -c -
```

Substitute the downloaded asset name. On macOS, pipe the matching manifest
line to `shasum -a 256 -c -`. On Windows, compare
`Get-FileHash -Algorithm SHA256` with the matching manifest line.

Every attached binary is built from the annotated v1.1.0 tag with `-trimpath`
and stripped debug data. Embedded Go metadata records the module, version,
source revision, target OS and architecture, and an unmodified source tree.
39 changes: 32 additions & 7 deletions internal/tui/cardeditor/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"
"unicode"

"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
Expand Down Expand Up @@ -167,12 +168,14 @@ type Model struct {
manualScroll bool
pointerState pointer.State
styles *theme.Styles
spin spinner.Model
}

// New creates a closed editor. A nil store keeps the feature unavailable in
// lightweight root-model tests.
func New(st Store, user string) Model {
m := Model{store: st, user: user, now: time.Now, ctx: context.Background(), styles: theme.New(true)}
m.spin = spinner.New(spinner.WithSpinner(m.styles.Spinner))
m.resetInputs()
return m
}
Expand All @@ -183,6 +186,7 @@ func New(st Store, user string) Model {
func (m *Model) SetStyles(styles *theme.Styles) {
if styles != nil {
m.styles = styles
m.spin.Spinner = styles.Spinner
}
}

Expand Down Expand Up @@ -243,13 +247,32 @@ func IsMessage(message tea.Msg) bool {
return true
}
switch message.(type) {
case labelsLoadedMsg, similarDebounceMsg, similarLoadedMsg, saveCompletedMsg, draftCompletedMsg, pointerClickMsg, pointerWheelMsg:
case labelsLoadedMsg, similarDebounceMsg, similarLoadedMsg, saveCompletedMsg, draftCompletedMsg,
pointerClickMsg, pointerWheelMsg, spinner.TickMsg:
return true
default:
return false
}
}

// busy reports whether a spinner-worthy operation is in flight. Spec section
// 5.2 names the editor's drafting and saving states for the bubbles spinner.
func (m Model) busy() bool { return m.drafting || m.saving }

// spinTick advances the busy indicator. The tick loop stops as soon as nothing
// is in flight, so an idle editor costs no timers.
func (m *Model) spinTick(msg spinner.TickMsg) tea.Cmd {
if !m.busy() {
return nil
}
var command tea.Cmd
m.spin, command = m.spin.Update(msg)
return command
}

// startSpinner is the command that begins the tick loop for a new operation.
func (m Model) startSpinner() tea.Cmd { return m.spin.Tick }

// OpenAdd resets the form for a card appended to status.
func (m *Model) OpenAdd(status board.Status) tea.Cmd {
if m.store == nil {
Expand Down Expand Up @@ -388,6 +411,8 @@ func (m *Model) Update(message tea.Msg) tea.Cmd {
return nil
}
switch msg := message.(type) {
case spinner.TickMsg:
return m.spinTick(msg)
case labelsLoadedMsg:
if msg.session != m.session {
return nil
Expand Down Expand Up @@ -620,7 +645,7 @@ func (m *Model) startDraft() tea.Cmd {
ctx, cancel := context.WithCancel(m.ctx)
m.draftCancel, m.drafting = cancel, true
m.statusMessage, m.statusIsError = "drafting card...", false
return func() tea.Msg {
return tea.Batch(m.startSpinner(), func() tea.Msg {
run, err := m.runner.RunSkill(ctx, m.user, ai.ScopeReadOnly, "story-draft", input, 1, draftMaxTokens)
if err == nil && len(run.Cards) == 0 {
err = errors.New("the model returned no usable card")
Expand All @@ -630,7 +655,7 @@ func (m *Model) startDraft() tea.Cmd {
draft = run.Cards[0]
}
return draftCompletedMsg{session: session, generation: generation, draft: draft, err: err}
}
})
}

func (m *Model) currentCardJSON() ([]byte, error) {
Expand Down Expand Up @@ -947,17 +972,17 @@ func (m *Model) startSave() tea.Cmd {
m.statusMessage, m.statusIsError = "saving card...", false
session := m.session
if m.mode == modeAdd {
return func() tea.Msg {
return tea.Batch(m.startSpinner(), func() tea.Msg {
created, saveErr := m.store.AddTask(m.user, task)
return saveCompletedMsg{session: session, task: created, err: saveErr}
}
})
}
id := m.base.ID
expected := expectedTaskFields(m.canonical, m.changedFields())
return func() tea.Msg {
return tea.Batch(m.startSpinner(), func() tea.Msg {
updated, saveErr := m.store.UpdateTaskIfFieldsMatch(m.user, id, expected, patch)
return saveCompletedMsg{session: session, task: updated, err: saveErr}
}
})
}

func expectedTaskFields(task board.Task, changed editedFields) store.TaskPatch {
Expand Down
46 changes: 35 additions & 11 deletions internal/tui/cardeditor/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"
"time"

"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"

Expand Down Expand Up @@ -133,7 +134,7 @@ func TestCreatePersistsEveryFieldAndAcknowledgesClose(t *testing.T) {
if !model.saving || save == nil {
t.Fatalf("save state = saving:%v command:%v", model.saving, save)
}
model.Update(save())
model.Update(commandMsgForEditor(t, save))
createdID, saved := model.ConsumeSaved()
_, savedAgain := model.ConsumeSaved()
if model.IsOpen() || !saved || createdID == "" || savedAgain {
Expand Down Expand Up @@ -167,7 +168,7 @@ func TestEditClearSemanticsAndRefusedSavePreserveForm(t *testing.T) {
model.effort = ""
backend.updateErr = errors.New("database refused\x1b[31m\nretry")
save := model.startSave()
model.Update(save())
model.Update(commandMsgForEditor(t, save))
if !model.IsOpen() || model.saving || model.title.Value() != "Edited" || model.due.Value() != "" || model.effort != "" {
t.Fatalf("refusal destroyed state: %+v", model.currentSnapshot())
}
Expand All @@ -180,7 +181,7 @@ func TestEditClearSemanticsAndRefusedSavePreserveForm(t *testing.T) {
}

backend.updateErr = nil
model.Update(model.startSave()())
model.Update(commandMsgForEditor(t, model.startSave()))
stored, _ = backend.Board("alice")
if stored.Tasks[0].Title != "Edited" || stored.Tasks[0].Due != "" || stored.Tasks[0].Effort != "" {
t.Fatalf("clears did not persist: %+v", stored.Tasks[0])
Expand Down Expand Up @@ -298,7 +299,7 @@ func TestEditMergesConcurrentUnrelatedStoreChanges(t *testing.T) {
if save == nil {
t.Fatalf("unrelated concurrent changes blocked save: %s", model.statusMessage)
}
model.Update(save())
model.Update(commandMsgForEditor(t, save))

latest, err := concurrentStore.Board("u")
if err != nil || len(latest.Tasks) != 1 {
Expand Down Expand Up @@ -359,7 +360,7 @@ func TestEditSaveCASRejectsLateSameFieldWriteAndPreservesLateUnrelatedWrite(t *t
if save == nil {
t.Fatalf("start save: %s", model.statusMessage)
}
model.Update(save())
model.Update(commandMsgForEditor(t, save))
latest, readErr := backend.Store.Task("u", created.ID)
if readErr != nil {
t.Fatal(readErr)
Expand Down Expand Up @@ -399,7 +400,7 @@ func TestEditSaveCASRejectsLateSameFieldWriteAndPreservesLateUnrelatedWrite(t *t
}

save := model.startSave()
model.Update(save())
model.Update(commandMsgForEditor(t, save))
latest, readErr := backend.Store.Task("u", created.ID)
if readErr != nil {
t.Fatal(readErr)
Expand Down Expand Up @@ -430,7 +431,7 @@ func TestEditSaveCASRejectsLateSameFieldWriteAndPreservesLateUnrelatedWrite(t *t
}

save := model.startSave()
model.Update(save())
model.Update(commandMsgForEditor(t, save))
latest, readErr := backend.Store.Task("u", created.ID)
if readErr != nil {
t.Fatal(readErr)
Expand Down Expand Up @@ -851,7 +852,7 @@ func TestKeyboardRoutesEveryFieldAndAction(t *testing.T) {
if save == nil {
t.Fatalf("keyboard save rejected: %s", model.statusMessage)
}
model.Update(save())
model.Update(commandMsgForEditor(t, save))
if model.IsOpen() {
t.Fatal("keyboard save did not close")
}
Expand All @@ -867,7 +868,7 @@ func TestCtrlEnterUsesTheKeyboardSavePath(t *testing.T) {
if save == nil || !model.saving {
t.Fatalf("ctrl+enter save command=%v saving=%v status=%q", save, model.saving, model.statusMessage)
}
model.Update(save())
model.Update(commandMsgForEditor(t, save))
if model.IsOpen() {
t.Fatal("ctrl+enter left the editor open")
}
Expand All @@ -888,7 +889,7 @@ func TestPointerFocusAndSaveUseTheRenderedHitRegions(t *testing.T) {
if start == nil || !model.saving {
t.Fatalf("pointer save did not enter saving state: status=%q", model.statusMessage)
}
model.Update(start())
model.Update(commandMsgForEditor(t, start))
if model.IsOpen() {
t.Fatal("pointer save left the editor open")
}
Expand Down Expand Up @@ -1427,10 +1428,33 @@ func TestAIDraftUnavailableBlankStaleAndShutdownBranches(t *testing.T) {
model.SetAIRunner(nil, context.Background())
}

// commandMsgForEditor runs a command and returns the editor message it
// produced. An operation that also starts the busy spinner returns a batch, so
// the batch is walked and the spinner tick - a timer, not a result - is
// skipped.
func commandMsgForEditor(t *testing.T, command tea.Cmd) tea.Msg {
t.Helper()
if command == nil {
t.Fatal("command is nil")
}
return command()
message := command()
batch, batched := message.(tea.BatchMsg)
if !batched {
return message
}
for _, sub := range batch {
if sub == nil {
continue
}
if result := sub(); !isSpinnerTick(result) {
return result
}
}
t.Fatal("batch produced no editor message")
return nil
}

func isSpinnerTick(message tea.Msg) bool {
_, tick := message.(spinner.TickMsg)
return tick
}
Loading