feat(tui): add interactive run mode for checklists and fill mode for templates

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.
This commit is contained in:
2026-05-17 21:53:55 -04:00
parent 1066c0bc7d
commit 77222ff1b8
8 changed files with 423 additions and 20 deletions
+26
View File
@@ -49,6 +49,10 @@ type absorbSourcesLoadedMsg struct {
entities []*db.Entity entities []*db.Entity
} }
type stepsPersistedMsg struct{}
type templateCopiedMsg struct{}
type tagsLoadedMsg struct { type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
@@ -235,3 +239,25 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
return entityAbsorbedMsg{targetID} 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{}
}
}
+37 -2
View File
@@ -11,11 +11,22 @@ import (
"github.com/lerko/nib/internal/display" "github.com/lerko/nib/internal/display"
) )
type detailMode int
const (
detailPreview detailMode = iota
detailRun
detailFill
)
type detailModel struct { type detailModel struct {
entity *db.Entity entity *db.Entity
scroll int scroll int
height int height int
width int width int
mode detailMode
run runModel
fill fillModel
} }
func newDetailModel() detailModel { func newDetailModel() detailModel {
@@ -25,6 +36,7 @@ func newDetailModel() detailModel {
func (d *detailModel) setEntity(e *db.Entity) { func (d *detailModel) setEntity(e *db.Entity) {
d.entity = e d.entity = e
d.scroll = 0 d.scroll = 0
d.mode = detailPreview
} }
func (d *detailModel) setSize(width, height int) { func (d *detailModel) setSize(width, height int) {
@@ -32,7 +44,16 @@ func (d *detailModel) setSize(width, height int) {
d.height = height d.height = height
} }
func (d detailModel) update(msg tea.KeyMsg) detailModel { 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() { switch msg.String() {
case "up", "k": case "up", "k":
if d.scroll > 0 { if d.scroll > 0 {
@@ -41,10 +62,22 @@ func (d detailModel) update(msg tea.KeyMsg) detailModel {
case "down", "j": case "down", "j":
d.scroll++ d.scroll++
} }
return d return d, nil
}
} }
func (d detailModel) view(width int) string { 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 { if d.entity == nil {
return "" return ""
} }
@@ -154,6 +187,7 @@ func renderCardData(e *db.Entity) string {
} }
progress := fmt.Sprintf(" %d/%d steps", done, len(steps)) progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
b.WriteString(detailLabelStyle.Render(progress)) b.WriteString(detailLabelStyle.Render(progress))
b.WriteString(" " + helpStyle.Render("r:run"))
case db.CardTemplate: case db.CardTemplate:
slots, ok := data["slots"].([]interface{}) slots, ok := data["slots"].([]interface{})
@@ -174,6 +208,7 @@ func renderCardData(e *db.Entity) string {
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
} }
b.WriteString(" " + helpStyle.Render("f:fill"))
case db.CardDecision: case db.CardDecision:
if chose, ok := data["chose"].(string); ok && chose != "" { if chose, ok := data["chose"].(string); ok && chose != "" {
+152
View File
@@ -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()
}
+14
View File
@@ -35,6 +35,20 @@ func renderHelp(width, height int) string {
{"c", "copy to clipboard"}, {"c", "copy to clipboard"},
{"e", "edit in $EDITOR"}, {"e", "edit in $EDITOR"},
{"!", "toggle pin"}, {"!", "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{ {"Global", [][2]string{
{"?", "toggle help"}, {"?", "toggle help"},
+4
View File
@@ -27,6 +27,8 @@ type keyMap struct {
Sort key.Binding Sort key.Binding
Intent key.Binding Intent key.Binding
Absorb key.Binding Absorb key.Binding
Run key.Binding
Fill key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -54,4 +56,6 @@ var keys = keyMap{
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), 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")),
} }
+60 -11
View File
@@ -216,6 +216,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateAbsorb m.state = stateAbsorb
return m, nil 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: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
m.state = stateTagFilter m.state = stateTagFilter
@@ -328,6 +338,18 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc": case "esc":
if m.state == stateDetail { 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 m.state = stateList
return m, nil return m, nil
} }
@@ -349,15 +371,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil 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": case "d":
if m.state == stateList { if m.state == stateList {
if e := m.selectedEntity(); e != nil { if e := m.selectedEntity(); e != nil {
@@ -435,10 +448,44 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case "e": 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, editInEditor(m.store, m.detail.entity)
} }
return m, nil 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 { switch m.state {
@@ -449,7 +496,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.list = m.list.update(msg) m.list = m.list.update(msg)
} }
case stateDetail: 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 return m, nil
} }
+116
View File
@@ -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()
}
+8 -1
View File
@@ -38,7 +38,14 @@ func countText(m model) string {
func contextHints(m model) string { func contextHints(m model) string {
switch m.state { switch m.state {
case stateDetail: 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: case stateInput:
return "enter:submit esc:cancel" return "enter:submit esc:cancel"
case stateTagFilter: case stateTagFilter: