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 .docket/ledger.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,4 @@
{"schema":3,"kind":"decision","id":"d99","text":"/glowup config opens the config TUI on native Windows","state":"adopted","ts":"2026-10-07T22:38:36+00:00","author":"claude-code","session":"","branch":"feat/config-tui","scope":["hooks/launch.ts","hooks/register.tsx"],"rationale":"The owner asked for cross-OS support; start honours the Windows 11 default-terminal setting, which covers Windows Terminal without a separate branch","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Unit-tested only; a wrong Windows step fails at first use on Windows and falls back to the printed command","pinned":false,"choice":"Detect Windows by OS=Windows_NT, fetch the windows_amd64 zip with curl.exe/tar.exe and PowerShell for folders and the checksum, launch with cmd.exe /c start so Windows 11 uses the default terminal app","alternatives":["Windows prints the command to run by hand","Launch wt.exe when WT_SESSION is set"],"decided_by":"owner"}
{"schema":3,"kind":"question","id":"q14","text":"Does cmd.exe /c start leave the config TUI holding process.run's pipes on Windows, so /glowup config waits 30 s and prints the fallback while the window is open?","state":"open","ts":"2026-10-08T10:46:42+00:00","author":"claude-code","session":"","branch":"feat/config-tui","scope":["hooks/launch.ts"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false}
{"schema":3,"kind":"claim","id":"c15","text":"The config TUI's Linux launch works live: tmux window, Konsole, $TERMINAL=xterm, hot reload, /clear mid-run, q and Esc","state":"accepted","ts":"2026-10-08T10:46:43+00:00","author":"claude-code","session":"","branch":"feat/config-tui","scope":["hooks/launch.ts","hooks/register.tsx"],"rationale":"macOS and Windows launches are unit-tested only","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"Task 11 live check 2026-10-08, task-11-report.md"}],"revisit":"","cost_if_wrong":"","pinned":false}
{"schema":3,"kind":"decision","id":"d100","text":"The config TUI draws the session's built-in pet playing its idle animation from frames baked into packs.json","state":"adopted","ts":"2026-10-08T12:30:13+00:00","author":"claude-code","session":"","branch":"fix/config-tui-pets","scope":["hooks/packexport.ts","installer/internal/tui/clawd.go","installer/internal/configtui/view.go"],"rationale":"The installer already embeds packs.json; built-ins cover the options list, and it adds about 350 KB to a 5.7 MB binary. The egg shows its uncracked stage because the installer has no session store.","supports":[["d96"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"User pets appear in the config TUI's pet options, or the egg's stage matters there","cost_if_wrong":"","pinned":false,"choice":"packexport.ts exports every BUILTIN_SHEETS pet's idle frames as half-block rows; the TUI picks the frame by tick time, holds frame 0 under reduced motion, and widens the column to the pet","alternatives":["Have the mod write the pet's frames into state.json each run, which would also cover user pets and the egg's crack stage"],"decided_by":""}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- The `/glowup config` window shows the robot and the egg, not only Clawd, and plays each pet's idle animation. With reduced motion on, the pet stands still. The egg is always drawn uncracked there.

## [0.12.0] - 2026-10-08

### Added
Expand Down
19 changes: 10 additions & 9 deletions hooks/packexport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolveLook, SPINNER_IDS, type Border, type RowStyle, type SpinnerId }
import { PACKS } from './packpresets.ts'
import { PRESETS, resolveTheme, type Colors } from './themes.ts'
import { SPINNERS, spinnerCells, type Cell } from './motion.ts'
import { CLAWD_SHEET, animFor, composeFrame, halfBlock, type PetSpan } from './pets.ts'
import { BUILTIN_SHEETS, animFor, composeFrame, halfBlock, petPalette, type PetSpan } from './pets.ts'

export type PackExport = {
name: string
Expand All @@ -25,9 +25,9 @@ export type ThemeExport = { name: string; word: string; colors: Colors }
// text is true when the spinner draws in the theme's text color instead of the spinner color (the eyes).
export type SpinnerExport = { id: SpinnerId; name: string; ms: number; text: boolean; frames: Cell[][][] }

export type ClawdExport = { cols: number; rows: PetSpan[][] }
export type PetExport = { cols: number; frames: { ms: number; rows: PetSpan[][] }[] }

export type Export = { packs: PackExport[]; themes: ThemeExport[]; spinners: SpinnerExport[]; clawd: ClawdExport }
export type Export = { packs: PackExport[]; themes: ThemeExport[]; spinners: SpinnerExport[]; pets: Record<string, PetExport> }

const FRAME_MS: Record<string, number> = { stock: 120, clawd: 220 }
const FRAMES: Record<string, number> = { stock: 6, clawd: 4, scanline: 29, ring: 16, signal: 67 }
Expand Down Expand Up @@ -71,12 +71,13 @@ export const spinnerExport = (): SpinnerExport[] => SPINNER_IDS.map(id => {
return { id, name: s.name, ms: Math.round(step), text: 'useFg' in s && s.useFg === true, frames: Array.from({ length: n }, (_, i) => spinnerCells(id, i * step, MONO)) }
})

// The idle pose's first frame, as the band draws it: no outfit, not mirrored.
export const clawdExport = (): ClawdExport => ({
cols: CLAWD_SHEET.w,
rows: halfBlock(composeFrame(CLAWD_SHEET, animFor(CLAWD_SHEET, 'idle').frames[0]!, [], false), CLAWD_SHEET.palette),
})
// The idle animation as the band draws it: no outfit, not mirrored. The egg is its uncracked stage,
// since the crack stage lives in the session's store and the installer has no session.
export const petExport = (): Record<string, PetExport> => Object.fromEntries(Object.entries(BUILTIN_SHEETS).map(([id, sheet]) => {
const palette = petPalette(sheet, id)
return [id, { cols: sheet.w, frames: animFor(sheet, 'idle').frames.map(f => ({ ms: f.ms, rows: halfBlock(composeFrame(sheet, f, [], false), palette) })) }]
}))

export const exportAll = (): Export => ({ packs: packExport(), themes: themeExport(), spinners: spinnerExport(), clawd: clawdExport() })
export const exportAll = (): Export => ({ packs: packExport(), themes: themeExport(), spinners: spinnerExport(), pets: petExport() })

export const packsJson = (): string => JSON.stringify(exportAll(), null, 2) + '\n'
17 changes: 12 additions & 5 deletions installer/internal/configtui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"cmp"
"os"
"strings"
"time"

"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/textinput"
Expand Down Expand Up @@ -235,11 +236,13 @@ func (m Model) screen(w, h int, l tui.Look) string {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ansi.Truncate(i.fg(c.Dim, false, "Make the window at least 36×13."), w, ""))
}
preview := w >= withPreview && h >= previewH+headerLines+statusLines+1
clawd := preview && w >= withClawd && m.snap.State.Pet == "clawd"
p, isPet := packs.PetByID(m.snap.State.Pet)
withPet := withPreview + gap + tui.PetWidth(p)
pet := preview && isPet && w >= withPet
blockW := paneW
switch {
case clawd:
blockW = withClawd
case pet:
blockW = withPet
case preview:
blockW = withPreview
}
Expand All @@ -254,8 +257,12 @@ func (m Model) screen(w, h int, l tui.Look) string {
cols = append(cols, strings.Repeat(" ", gap),
tui.PreviewAt(l, claude.Choice{Pet: m.snap.State.Pet, Bubbles: m.snap.State.Bubbles, ReducedMotion: m.snap.State.Reduced}, m.ticks, previewW))
}
if clawd {
cols = append(cols, strings.Repeat(" ", gap), strings.Join(tui.ClawdColumnIn("pet clawd", l), "\n"))
if pet {
elapsed := m.ticks * int(tickEvery/time.Millisecond)
if m.snap.State.Reduced {
elapsed = 0
}
cols = append(cols, strings.Repeat(" ", gap), strings.Join(tui.PetColumnIn(p, "pet "+m.snap.State.Pet, l, elapsed), "\n"))
}
var i ink
status := i.fg(c.Fail, false, ansi.Truncate(m.status(), blockW, "…"))
Expand Down
55 changes: 55 additions & 0 deletions installer/internal/configtui/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import (

"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"

"github.com/novusedge/glowup/installer/internal/packs"
"github.com/novusedge/glowup/installer/internal/tui"
)

func viewAt(m Model, w, h int) []string {
Expand Down Expand Up @@ -101,6 +104,58 @@ func TestClawdColumnAppearsFrom104Columns(t *testing.T) {
}
}

func TestEveryBuiltInPetGetsAColumnWideEnoughForIt(t *testing.T) {
m, _ := newModel(t)
for pet, from := range map[string]int{"robot": withPreview + gap + 32, "egg": withClawd, "clawd-shiny": withClawd} {
m.snap.State.Pet = pet
// The preview's footer says "pet <id>" too; the bubble is the second.
for w, want := range map[int]int{from - 1: 1, from: 2} {
if got := strings.Count(strings.Join(viewAt(m, w, 40), "\n"), "pet "+pet); got != want {
t.Fatalf("%s at width %d: %d mentions, want %d", pet, w, got, want)
}
}
}
m.snap.State.Pet = "off"
if n := strings.Count(strings.Join(viewAt(m, 200, 40), "\n"), "pet off"); n != 1 {
t.Fatalf("no pet: %d mentions of it", n)
}
}

// petColumn is what a 200-wide view draws right of the preview, where only the pet moves.
func petColumn(m Model) string {
p, _ := packs.PetByID(m.snap.State.Pet)
from := (200-(withPreview+gap+tui.PetWidth(p)))/2 + withPreview
var out []string
for _, l := range viewAt(m, 200, 50) {
out = append(out, ansi.Cut(l, from, 200))
}
return strings.Join(out, "\n")
}

func TestThePetPlaysItsIdleAnimationUnlessMotionIsReduced(t *testing.T) {
for _, pet := range []string{"clawd", "robot", "egg"} {
m, _ := newModel(t)
m.snap.State.Pet = pet
seen := map[string]bool{}
for ticks := 0; ticks < 300; ticks += 3 {
m.ticks = ticks
seen[petColumn(m)] = true
}
if len(seen) < 2 {
t.Errorf("%s does not move over 30 s", pet)
}
m.snap.State.Reduced = true
still := map[string]bool{}
for ticks := 0; ticks < 300; ticks += 3 {
m.ticks = ticks
still[petColumn(m)] = true
}
if len(still) != 1 {
t.Errorf("%s moves under reduced motion", pet)
}
}
}

func TestThePreviewTitleCarriesABuiltInPacksDescription(t *testing.T) {
m, _ := newModel(t)
m.snap.Look = LookInfo{Name: "crt", Border: "bold"}
Expand Down
45 changes: 35 additions & 10 deletions installer/internal/packs/packs.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,41 @@ type SpinnerAnim struct {
Frames [][][]Cell `json:"frames"`
}

// Span is a run of Clawd's half-block cells in one color.
// Span is a run of a pet's half-block cells in one color.
type Span struct {
Text string `json:"text"`
Color string `json:"color"`
Bg string `json:"bg,omitempty"`
}

// Clawd is the idle pose's first frame as rows of spans, each Cols cells wide.
type Clawd struct {
Cols int `json:"cols"`
// PetFrame is one frame of a pet's idle animation, shown for Ms, as rows of spans.
type PetFrame struct {
Ms int `json:"ms"`
Rows [][]Span `json:"rows"`
}

// Pet is a built-in pet's looping idle animation; every row is Cols cells wide.
type Pet struct {
Cols int `json:"cols"`
Frames []PetFrame `json:"frames"`
}

// FrameAt is the frame showing elapsedMs into the loop.
func (p Pet) FrameAt(elapsedMs int) PetFrame {
total := 0
for _, f := range p.Frames {
total += f.Ms
}
t := elapsedMs % total
for _, f := range p.Frames {
if t < f.Ms {
return f
}
t -= f.Ms
}
return p.Frames[len(p.Frames)-1]
}

type Pack struct {
Name string `json:"name"`
Description string `json:"description"`
Expand All @@ -84,10 +106,10 @@ type Pack struct {
}

type file struct {
Packs []Pack `json:"packs"`
Themes []Theme `json:"themes"`
Spinners []SpinnerAnim `json:"spinners"`
Clawd Clawd `json:"clawd"`
Packs []Pack `json:"packs"`
Themes []Theme `json:"themes"`
Spinners []SpinnerAnim `json:"spinners"`
Pets map[string]Pet `json:"pets"`
}

var data = mustParse(raw)
Expand Down Expand Up @@ -171,5 +193,8 @@ func SpinnerByID(id string) (SpinnerAnim, bool) {
return SpinnerAnim{}, false
}

// ClawdSprite returns Clawd's idle frame.
func ClawdSprite() Clawd { return data.Clawd }
// PetByID returns a built-in pet, or false for "off" and user pets, which packs.json does not carry.
func PetByID(id string) (Pet, bool) {
p, ok := data.Pets[id]
return p, ok
}
Loading
Loading