Skip to content

Commit 53b624c

Browse files
authored
feat(tui): restyle the ADR split and import overlays (#144) (#150)
Slice 5 of the map #136 cut: the ADR split review flow and the forge import flow move onto internal/tui/theme and internal/tui/widget, and the two overlay-adjacent leftovers the earlier slices recorded are taken with them - the section 4 step 1 backdrop dim and the pointer package's raw reverse-video escape. Both overlays are widget.Overlay panels: the rounded borders are deleted, rows carry a semantic kind that picks the token, pointer targets are structural, and the busy states adopt the bubbles spinner and progress bar. adrsplit/view.go and issueimport/view.go leave the seam allowlist, which is down to one entry.
1 parent 307ae42 commit 53b624c

32 files changed

Lines changed: 1683 additions & 494 deletions

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
2929
github.com/aymerick/douceur v0.2.0 // indirect
3030
github.com/catppuccin/go v0.2.0 // indirect
31+
github.com/charmbracelet/harmonica v0.2.0 // indirect
3132
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
3233
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
3334
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
2828
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
2929
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
3030
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
31+
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
32+
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
3133
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
3234
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
3335
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=

internal/tui/adrsplit/model.go

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"io"
99
"os"
1010
"strings"
11+
"sync"
1112
"unicode"
1213
"unicode/utf8"
1314

15+
"charm.land/bubbles/v2/spinner"
1416
"charm.land/bubbles/v2/textarea"
1517
"charm.land/bubbles/v2/textinput"
1618
tea "charm.land/bubbletea/v2"
@@ -20,6 +22,7 @@ import (
2022
"github.com/RandomCodeSpace/kb/internal/board"
2123
"github.com/RandomCodeSpace/kb/internal/store"
2224
"github.com/RandomCodeSpace/kb/internal/tui/pointer"
25+
"github.com/RandomCodeSpace/kb/internal/tui/theme"
2326
)
2427

2528
const (
@@ -126,6 +129,32 @@ type Model struct {
126129
scroll int
127130
manualScroll bool
128131
pointerState pointer.State
132+
styles *theme.Styles
133+
spin spinner.Model
134+
}
135+
136+
// SetStyles hands the overlay the resolved design system. Spec section 6.2:
137+
// styles are built once by the root and threaded down, never constructed here.
138+
func (m *Model) SetStyles(styles *theme.Styles) {
139+
if styles == nil {
140+
return
141+
}
142+
m.styles = styles
143+
m.spin.Spinner = styles.Spinner
144+
}
145+
146+
// fallbackStyles is the palette a Model rendered without SetStyles draws with.
147+
// It is built once and never mutated; the root resolves its own on construction
148+
// and again on tea.BackgroundColorMsg (spec section 6.3).
149+
var fallbackStyles = sync.OnceValue(func() *theme.Styles { return theme.New(true) })
150+
151+
// themeStyles is the resolved design system, defaulting to the dark reference
152+
// palette until the root hands over its own.
153+
func (m Model) themeStyles() *theme.Styles {
154+
if m.styles != nil {
155+
return m.styles
156+
}
157+
return fallbackStyles()
129158
}
130159

131160
// New creates a closed overlay. Nil dependencies keep the feature unavailable
@@ -135,6 +164,7 @@ func New(st Store, runner Runner, user string, ctx context.Context) Model {
135164
ctx = context.Background()
136165
}
137166
m := Model{store: st, runner: runner, user: user, ctx: ctx}
167+
m.spin = spinner.New(spinner.WithSpinner(fallbackStyles().Spinner))
138168
m.resetInputs()
139169
return m
140170
}
@@ -193,13 +223,31 @@ func IsMessage(message tea.Msg) bool {
193223
return true
194224
}
195225
switch message.(type) {
196-
case fileLoadedMsg, splitCompletedMsg, cardAddedMsg, pointerActionMsg:
226+
case fileLoadedMsg, splitCompletedMsg, cardAddedMsg, pointerActionMsg, spinner.TickMsg:
197227
return true
198228
default:
199229
return false
200230
}
201231
}
202232

233+
// busy reports whether a spinner-worthy operation is in flight.
234+
func (m Model) busy() bool { return m.operation != "" || m.adding }
235+
236+
// spinTick advances the busy indicator. Spec section 5.2 adopts the bubbles
237+
// spinner for every busy state that used to be static text; the tick loop stops
238+
// as soon as nothing is in flight, so an idle overlay costs no timers.
239+
func (m *Model) spinTick(msg spinner.TickMsg) tea.Cmd {
240+
if !m.busy() {
241+
return nil
242+
}
243+
var command tea.Cmd
244+
m.spin, command = m.spin.Update(msg)
245+
return command
246+
}
247+
248+
// startSpinner is the command that begins the tick loop for a new operation.
249+
func (m Model) startSpinner() tea.Cmd { return m.spin.Tick }
250+
203251
// Update applies user input and scoped asynchronous results.
204252
func (m *Model) Update(message tea.Msg) tea.Cmd {
205253
if !m.open {
@@ -211,6 +259,8 @@ func (m *Model) Update(message tea.Msg) tea.Cmd {
211259
return command
212260
}
213261
switch msg := message.(type) {
262+
case spinner.TickMsg:
263+
return m.spinTick(msg)
214264
case fileLoadedMsg:
215265
if msg.session != m.session || msg.generation != m.generation || m.operation != "reading file" {
216266
return nil
@@ -468,10 +518,10 @@ func (m *Model) startSplit() tea.Cmd {
468518
ctx, cancel := context.WithCancel(m.ctx)
469519
m.cancel, m.operation = cancel, "reading file"
470520
m.status, m.statusIsError = "reading ADR file...", false
471-
return func() tea.Msg {
521+
return tea.Batch(m.startSpinner(), func() tea.Msg {
472522
text, err := readADRFile(ctx, path)
473523
return fileLoadedMsg{session: session, generation: generation, text: text, err: err}
474-
}
524+
})
475525
}
476526

477527
func (m *Model) startRun(text string) tea.Cmd {
@@ -480,10 +530,10 @@ func (m *Model) startRun(text string) tea.Cmd {
480530
ctx, cancel := context.WithCancel(m.ctx)
481531
m.cancel, m.operation = cancel, "splitting ADR"
482532
m.status, m.statusIsError = "splitting ADR...", false
483-
return func() tea.Msg {
533+
return tea.Batch(m.startSpinner(), func() tea.Msg {
484534
run, err := m.runner.RunSkill(ctx, m.user, ai.ScopeReadOnly, "adr-split", text, maximum, splitMaxTokens)
485535
return splitCompletedMsg{session: session, generation: generation, run: run, err: err}
486-
}
536+
})
487537
}
488538

489539
func (m *Model) cancelOperation() {
@@ -532,7 +582,7 @@ func (m *Model) startAdd() tea.Cmd {
532582
m.adding, m.addPosition = true, 0
533583
m.addGeneration++
534584
m.status, m.statusIsError = fmt.Sprintf("creating card 1 of %d...", len(m.addQueue)), false
535-
return m.addNext()
585+
return tea.Batch(m.startSpinner(), m.addNext())
536586
}
537587

538588
func (m *Model) addNext() tea.Cmd {
@@ -800,8 +850,11 @@ func cycleInt(value, low, high int, key string) int {
800850
return value
801851
}
802852

853+
// effortValues is the effort cycle, in order. The view names the same order.
854+
func effortValues() []string { return []string{"", "S", "M", "L"} }
855+
803856
func cycleEffort(value, key string) string {
804-
values := []string{"", "S", "M", "L"}
857+
values := effortValues()
805858
index := 0
806859
for i, candidate := range values {
807860
if candidate == value {

internal/tui/adrsplit/model_test.go

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"testing"
1313

14+
"charm.land/bubbles/v2/spinner"
1415
tea "charm.land/bubbletea/v2"
1516
"github.com/charmbracelet/x/ansi"
1617

@@ -66,12 +67,34 @@ func newTestModel() (*Model, *fakeStore, *fakeRunner) {
6667
return &m, st, runner
6768
}
6869

70+
// commandMsg runs a command and returns the overlay message it produced. An
71+
// operation that also starts the busy spinner returns a batch, so the batch is
72+
// walked and the spinner tick - which is a timer, not a result - is skipped.
6973
func commandMsg(t *testing.T, command tea.Cmd) tea.Msg {
7074
t.Helper()
7175
if command == nil {
7276
t.Fatal("command is nil")
7377
}
74-
return command()
78+
message := command()
79+
batch, batched := message.(tea.BatchMsg)
80+
if !batched {
81+
return message
82+
}
83+
for _, sub := range batch {
84+
if sub == nil {
85+
continue
86+
}
87+
if result := sub(); !isSpinnerTick(result) {
88+
return result
89+
}
90+
}
91+
t.Fatal("batch produced no overlay message")
92+
return nil
93+
}
94+
95+
func isSpinnerTick(message tea.Msg) bool {
96+
_, tick := message.(spinner.TickMsg)
97+
return tick
7598
}
7699

77100
func TestAvailabilitySessionsAndCloseLifecycle(t *testing.T) {
@@ -543,7 +566,7 @@ func TestPointerControlsAreRenderDerivedAndSessionScoped(t *testing.T) {
543566
m.rows = rowsFromDrafts([]ai.Draft{testDraft("one"), testDraft("two")})
544567
m.focus = "include:0"
545568
handler = m.MouseHandler(80, 24)
546-
line, x = visibleTextPosition(t, m.View(80, 24), "[x] include")
569+
line, x = visibleTextPosition(t, m.View(80, 24), "1 include")
547570
message = pointerRelease(m, handler, x, line)()
548571
if _, ok := message.(pointerActionMsg); !ok {
549572
t.Fatalf("review release message=%T", message)
@@ -580,7 +603,7 @@ func TestPointerControlsClipScrolledRowsAndGuardBusyWork(t *testing.T) {
580603
t.Fatal("small viewport rendered an offscreen row")
581604
}
582605
handler := m.MouseHandler(50, 10)
583-
line, x := visibleTextPosition(t, view, "[x] include")
606+
line, x := visibleTextPosition(t, view, "1 include")
584607
if command := pointerRelease(m, handler, x, line); command == nil {
585608
t.Fatal("visible review control had no hit region")
586609
}
@@ -686,7 +709,7 @@ func TestPointerControlsActivateEveryVisibleInputAndReviewControl(t *testing.T)
686709
review, _, _ := newTestModel()
687710
review.stage = stageReview
688711
review.rows = rowsFromDrafts([]ai.Draft{testDraft("one"), testDraft("two")})
689-
pointerActivate(t, review, "[x] include")
712+
pointerActivate(t, review, "1 include")
690713
if review.rows[0].include {
691714
t.Fatal("pointer include did not toggle the selected row")
692715
}
@@ -790,8 +813,11 @@ func TestDirtyCloseButtonActivatesAfterPressedRerender(t *testing.T) {
790813
if press == nil || m.Update(press()) != nil {
791814
t.Fatal("dirty-close discard did not enter pressed state")
792815
}
793-
if view := ansi.Strip(m.View(80, 24)); !strings.Contains(view, "[>Discard<]") {
794-
t.Fatalf("dirty-close discard omitted pressed feedback:\n%s", view)
816+
// The feedback is theme.Styles.Pressed now, not a text substitution, so it
817+
// is the reverse-video attribute in the composed frame that says "pressed".
818+
view := m.View(80, 24)
819+
if !strings.Contains(view, "\x1b[7m") || !strings.Contains(ansi.Strip(view), "[ Discard ]") {
820+
t.Fatalf("dirty-close discard omitted pressed feedback:\n%s", ansi.Strip(view))
795821
}
796822
release := m.MouseHandler(80, 24)(tea.MouseReleaseMsg{X: x, Y: line, Button: tea.MouseNone})
797823
if release == nil {
@@ -826,20 +852,25 @@ func TestCreatedReviewRowsKeepPointerTargetsAligned(t *testing.T) {
826852
m.stage = stageReview
827853
m.rows = rowsFromDrafts([]ai.Draft{testDraft("created"), testDraft("pending")})
828854
m.rows[0].created = true
829-
lines := m.bodyLines(80)
830-
targets := m.controlRows()
831-
for line, rendered := range lines {
855+
for _, row := range m.bodyRows(80) {
832856
want := ""
833857
switch {
834-
case strings.Contains(rendered, "Destination:"):
858+
case strings.Contains(row.plain(), "Destination:"):
835859
want = "dest"
836-
case strings.Contains(rendered, "[ Back to source ]"):
860+
case strings.Contains(row.plain(), "[ Back to source ]"):
837861
want = "back"
838-
case strings.Contains(rendered, "[ Add selected"):
862+
case strings.Contains(row.plain(), "[ Add selected"):
839863
want = "add"
840864
}
841-
if want != "" && targets[line] != want {
842-
t.Fatalf("line %d %q target=%q want %q", line, rendered, targets[line], want)
865+
if want != "" && row.target != want {
866+
t.Fatalf("row %q target=%q want %q", row.plain(), row.target, want)
867+
}
868+
}
869+
// A created story keeps its rows in the list but never a focus target, so
870+
// the batch cannot be told to write it twice.
871+
for _, row := range m.bodyRows(80) {
872+
if strings.Contains(row.plain(), "created") && row.target != "" {
873+
t.Fatalf("created row %q carries target %q", row.plain(), row.target)
843874
}
844875
}
845876
}

0 commit comments

Comments
 (0)