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
+43 -8
View File
@@ -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 != "" {