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
+60 -11
View File
@@ -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
}