From 77222ff1b81d48fb8422f76b347cebfd5fe177fc Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 21:53:55 -0400 Subject: [PATCH] feat(tui): add interactive run mode for checklists and fill mode for templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run mode (r key on checklist cards): cursor navigates steps, space toggles done/undone, r resets all, esc saves changes to DB and exits. Persists step state — improvement over web which discards on exit. Fill mode (f key on template cards): tab/shift-tab navigates slots, type to fill values, enter resolves template and copies to clipboard with use count increment. Esc cancels without copying. Both modes are sub-states of detail view, keeping architecture simple. --- internal/tui/commands.go | 26 +++++++ internal/tui/detail.go | 51 +++++++++++-- internal/tui/fill.go | 152 ++++++++++++++++++++++++++++++++++++++ internal/tui/help.go | 14 ++++ internal/tui/keys.go | 4 + internal/tui/model.go | 71 +++++++++++++++--- internal/tui/run.go | 116 +++++++++++++++++++++++++++++ internal/tui/statusbar.go | 9 ++- 8 files changed, 423 insertions(+), 20 deletions(-) create mode 100644 internal/tui/fill.go create mode 100644 internal/tui/run.go diff --git a/internal/tui/commands.go b/internal/tui/commands.go index d93281e..a247cd3 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -49,6 +49,10 @@ type absorbSourcesLoadedMsg struct { entities []*db.Entity } +type stepsPersistedMsg struct{} + +type templateCopiedMsg struct{} + type tagsLoadedMsg struct { tags []db.TagCount } @@ -235,3 +239,25 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd { return entityAbsorbedMsg{targetID} } } + +func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd { + return func() tea.Msg { + update := db.EntityUpdate{CardData: &stepsJSON} + if err := store.Update(entityID, &update); err != nil { + return errMsg{err} + } + return stepsPersistedMsg{} + } +} + +func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { + return func() tea.Msg { + if err := clipboard.WriteAll(resolved); err != nil { + return errMsg{err} + } + if err := store.IncrementUse(entityID); err != nil { + return errMsg{err} + } + return templateCopiedMsg{} + } +} diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 1cc8376..38e5f46 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -11,11 +11,22 @@ import ( "github.com/lerko/nib/internal/display" ) +type detailMode int + +const ( + detailPreview detailMode = iota + detailRun + detailFill +) + type detailModel struct { entity *db.Entity scroll int height int width int + mode detailMode + run runModel + fill fillModel } func newDetailModel() detailModel { @@ -25,6 +36,7 @@ func newDetailModel() detailModel { func (d *detailModel) setEntity(e *db.Entity) { d.entity = e d.scroll = 0 + d.mode = detailPreview } func (d *detailModel) setSize(width, height int) { @@ -32,19 +44,40 @@ func (d *detailModel) setSize(width, height int) { d.height = height } -func (d detailModel) update(msg tea.KeyMsg) detailModel { - switch msg.String() { - case "up", "k": - if d.scroll > 0 { - d.scroll-- +func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) { + switch d.mode { + case detailRun: + d.run = d.run.update(msg.String()) + return d, nil + case detailFill: + var cmd tea.Cmd + d.fill, cmd = d.fill.update(msg) + return d, cmd + default: + switch msg.String() { + case "up", "k": + if d.scroll > 0 { + d.scroll-- + } + case "down", "j": + d.scroll++ } - case "down", "j": - d.scroll++ + return d, nil } - return d } func (d detailModel) view(width int) string { + switch d.mode { + case detailRun: + return d.run.view(width) + case detailFill: + return d.fill.view(width) + default: + return d.previewView(width) + } +} + +func (d detailModel) previewView(width int) string { if d.entity == nil { return "" } @@ -154,6 +187,7 @@ func renderCardData(e *db.Entity) string { } progress := fmt.Sprintf(" %d/%d steps", done, len(steps)) b.WriteString(detailLabelStyle.Render(progress)) + b.WriteString(" " + helpStyle.Render("r:run")) case db.CardTemplate: slots, ok := data["slots"].([]interface{}) @@ -174,6 +208,7 @@ func renderCardData(e *db.Entity) string { } b.WriteString(line + "\n") } + b.WriteString(" " + helpStyle.Render("f:fill")) case db.CardDecision: if chose, ok := data["chose"].(string); ok && chose != "" { diff --git a/internal/tui/fill.go b/internal/tui/fill.go new file mode 100644 index 0000000..ec9d2f8 --- /dev/null +++ b/internal/tui/fill.go @@ -0,0 +1,152 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/carddata" +) + +type fillSlot struct { + Name string + Default string + Value string +} + +type fillModel struct { + slots []fillSlot + active int + body string + entityID string + ti textinput.Model +} + +func newFillModel(entityID, body string) fillModel { + slots := discoverSlots(body) + m := fillModel{ + slots: slots, + body: body, + entityID: entityID, + } + m.ti = textinput.New() + m.ti.CharLimit = 200 + if len(slots) > 0 { + m.ti.Placeholder = slots[0].Name + m.ti.Focus() + if slots[0].Default != "" { + m.ti.SetValue(slots[0].Default) + } + } + return m +} + +func discoverSlots(body string) []fillSlot { + matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1) + seen := map[string]bool{} + var slots []fillSlot + for _, m := range matches { + name := m[1] + if !seen[name] { + seen[name] = true + slots = append(slots, fillSlot{Name: name}) + } + } + return slots +} + +func (f fillModel) resolve() string { + result := f.body + for _, s := range f.slots { + val := s.Value + if val == "" { + val = "${" + s.Name + "}" + } + result = strings.ReplaceAll(result, "${"+s.Name+"}", val) + } + return result +} + +func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) { + switch msg.String() { + case "tab": + f.commitActive() + if f.active < len(f.slots)-1 { + f.active++ + } else { + f.active = 0 + } + f.loadActive() + return f, nil + case "shift+tab": + f.commitActive() + if f.active > 0 { + f.active-- + } else { + f.active = len(f.slots) - 1 + } + f.loadActive() + return f, nil + } + + f.ti, _ = f.ti.Update(msg) + return f, nil +} + +func (f *fillModel) commitActive() { + if f.active < len(f.slots) { + f.slots[f.active].Value = f.ti.Value() + } +} + +func (f *fillModel) loadActive() { + if f.active < len(f.slots) { + s := f.slots[f.active] + f.ti.SetValue(s.Value) + f.ti.Placeholder = s.Name + f.ti.Focus() + } +} + +func (f fillModel) view(width int) string { + if len(f.slots) == 0 { + return statusStyle.Render("no slots") + } + + var b strings.Builder + header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots)) + b.WriteString(detailHeaderStyle.Render(header)) + b.WriteString("\n\n") + + for i, slot := range f.slots { + name := detailLabelStyle.Render(slot.Name) + var val string + if i == f.active { + val = f.ti.View() + } else if slot.Value != "" { + val = detailValueStyle.Render(slot.Value) + } else { + val = idStyle.Render("(empty)") + } + + if i == f.active { + b.WriteString(selectedItemStyle.Render(" " + name + " " + val)) + } else { + b.WriteString(listItemStyle.Render(name + " " + val)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + preview := f.resolve() + if len(preview) > width-4 { + preview = preview[:width-7] + "…" + } + b.WriteString(detailBodyStyle.Render(preview)) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel")) + + return b.String() +} diff --git a/internal/tui/help.go b/internal/tui/help.go index bfec228..ea3e080 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -35,6 +35,20 @@ func renderHelp(width, height int) string { {"c", "copy to clipboard"}, {"e", "edit in $EDITOR"}, {"!", "toggle pin"}, + {"r", "run checklist"}, + {"f", "fill template"}, + }}, + {"Run Mode", [][2]string{ + {"j/k", "move between steps"}, + {"space", "toggle step"}, + {"r", "reset all steps"}, + {"esc", "save + exit"}, + }}, + {"Fill Mode", [][2]string{ + {"tab", "next slot"}, + {"shift+tab", "prev slot"}, + {"enter", "copy resolved"}, + {"esc", "cancel"}, }}, {"Global", [][2]string{ {"?", "toggle help"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 5fae286..e755232 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -27,6 +27,8 @@ type keyMap struct { Sort key.Binding Intent key.Binding Absorb key.Binding + Run key.Binding + Fill key.Binding } var keys = keyMap{ @@ -54,4 +56,6 @@ var keys = keyMap{ Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), + Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), + Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index bd1be9a..bf8c39a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -216,6 +216,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateAbsorb return m, nil + case stepsPersistedMsg: + m.status = "steps saved" + m.detail.mode = detailPreview + return m, m.reloadDetail(m.detail.entity.ID) + + case templateCopiedMsg: + m.status = "copied resolved" + m.detail.mode = detailPreview + return m, loadEntities(m.store, m.listParams()) + case tagsLoadedMsg: m.filter.setTags(msg.tags) m.state = stateTagFilter @@ -328,6 +338,18 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": if m.state == stateDetail { + if m.detail.mode == detailRun { + var cmd tea.Cmd + if m.detail.run.dirty { + cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON()) + } + m.detail.mode = detailPreview + return m, cmd + } + if m.detail.mode == detailFill { + m.detail.mode = detailPreview + return m, nil + } m.state = stateList return m, nil } @@ -349,15 +371,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil - case "enter": - if m.state == stateList { - if e := m.selectedEntity(); e != nil { - m.detail.setEntity(e) - m.state = stateDetail - } - } - return m, nil - case "d": if m.state == stateList { if e := m.selectedEntity(); e != nil { @@ -435,10 +448,44 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "e": - if m.state == stateDetail && m.detail.entity != nil { + if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { return m, editInEditor(m.store, m.detail.entity) } return m, nil + + case "r": + if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { + if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist { + m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData) + m.detail.mode = detailRun + return m, nil + } + } + return m, nil + + case "f": + if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { + if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate { + m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body) + m.detail.mode = detailFill + return m, m.detail.fill.ti.Focus() + } + } + return m, nil + + case "enter": + if m.state == stateDetail && m.detail.mode == detailFill { + m.detail.fill.commitActive() + resolved := m.detail.fill.resolve() + return m, copyResolved(m.store, m.detail.fill.entityID, resolved) + } + if m.state == stateList { + if e := m.selectedEntity(); e != nil { + m.detail.setEntity(e) + m.state = stateDetail + } + } + return m, nil } switch m.state { @@ -449,7 +496,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.list = m.list.update(msg) } case stateDetail: - m.detail = m.detail.update(msg) + var cmd tea.Cmd + m.detail, cmd = m.detail.update(msg) + return m, cmd } return m, nil } diff --git a/internal/tui/run.go b/internal/tui/run.go new file mode 100644 index 0000000..9d37013 --- /dev/null +++ b/internal/tui/run.go @@ -0,0 +1,116 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" +) + +type runStep struct { + Text string `json:"text"` + Done bool `json:"done"` +} + +type runModel struct { + steps []runStep + cursor int + entityID string + dirty bool +} + +func newRunModel(entityID string, cardData *string) runModel { + m := runModel{entityID: entityID} + m.steps = parseChecklist(cardData) + return m +} + +func parseChecklist(cardData *string) []runStep { + if cardData == nil { + return nil + } + var data struct { + Steps []runStep `json:"steps"` + } + if err := json.Unmarshal([]byte(*cardData), &data); err != nil { + return nil + } + return data.Steps +} + +func (r runModel) stepsJSON() string { + b, _ := json.Marshal(map[string]any{"steps": r.steps}) + return string(b) +} + +func (r runModel) doneCount() int { + n := 0 + for _, s := range r.steps { + if s.Done { + n++ + } + } + return n +} + +func (r runModel) update(key string) runModel { + switch key { + case "up", "k": + if r.cursor > 0 { + r.cursor-- + } + case "down", "j": + if r.cursor < len(r.steps)-1 { + r.cursor++ + } + case " ": + if r.cursor < len(r.steps) { + r.steps[r.cursor].Done = !r.steps[r.cursor].Done + r.dirty = true + } + case "r": + for i := range r.steps { + r.steps[i].Done = false + } + r.dirty = true + } + return r +} + +func (r runModel) view(width int) string { + if len(r.steps) == 0 { + return statusStyle.Render("no steps") + } + + var b strings.Builder + done := r.doneCount() + total := len(r.steps) + pct := 0 + if total > 0 { + pct = done * 100 / total + } + + header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct) + b.WriteString(detailHeaderStyle.Render(header)) + b.WriteString("\n\n") + + for i, step := range r.steps { + var line string + if step.Done { + line = checkDoneStyle.Render("[✓] " + step.Text) + } else { + line = checkPendingStyle.Render("[ ] " + step.Text) + } + + if i == r.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit")) + + return b.String() +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index bfde093..03eff2b 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -38,7 +38,14 @@ func countText(m model) string { func contextHints(m model) string { switch m.state { case stateDetail: - return "p:promote D:demote c:copy e:edit !:pin esc:back" + switch m.detail.mode { + case detailRun: + return "space:toggle j/k:nav r:reset esc:save+exit" + case detailFill: + return "tab:next shift+tab:prev enter:copy esc:cancel" + default: + return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" + } case stateInput: return "enter:submit esc:cancel" case stateTagFilter: