Files
lerko 77222ff1b8 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.
2026-05-17 21:53:55 -04:00

153 lines
3.0 KiB
Go

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()
}