feat(tui): add cards view, mode switching, promote picker, and card detail

Stream/cards toggle with 1/2 keys. Cards view with intent filtering
(tab cycles grab/read/fill/all), sort cycling (s key), pinned-first
ordering, and affordance badges. Promote picker (p key) with card type
selection and auto-detection from body content. Detail view renders
card_data per type: checklist steps, template slots, decision fields,
link URLs.

Extracts generateCardData to internal/carddata for reuse across cmd
and tui packages.
This commit is contained in:
2026-05-17 21:14:14 -04:00
parent c2ea63dd16
commit ce335cabd6
12 changed files with 786 additions and 112 deletions
+102
View File
@@ -0,0 +1,102 @@
package carddata
import (
"encoding/json"
"regexp"
"strings"
"github.com/lerko/nib/internal/db"
)
var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
func GenerateCardData(ct db.CardType, body string) *string {
var data string
switch ct {
case db.CardTemplate:
matches := TemplateSlotRe.FindAllStringSubmatch(body, -1)
type slot struct {
Name string `json:"name"`
Default string `json:"default"`
}
var slots []slot
seen := map[string]bool{}
for _, m := range matches {
name := m[1]
if !seen[name] {
slots = append(slots, slot{Name: name, Default: ""})
seen[name] = true
}
}
if slots == nil {
slots = []slot{}
}
b, _ := json.Marshal(map[string]any{"slots": slots})
data = string(b)
case db.CardChecklist:
type step struct {
Text string `json:"text"`
Done bool `json:"done"`
}
var steps []step
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
text := strings.TrimSpace(line[3:])
done := strings.HasPrefix(line, "[x]")
steps = append(steps, step{Text: text, Done: done})
}
}
if steps == nil {
steps = []step{{Text: body, Done: false}}
}
b, _ := json.Marshal(map[string]any{"steps": steps})
data = string(b)
case db.CardDecision:
b, _ := json.Marshal(map[string]any{
"chose": "",
"why": "",
"rejected": []string{},
})
data = string(b)
case db.CardLink:
url := ""
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
url = word
break
}
}
b, _ := json.Marshal(map[string]any{"url": url})
data = string(b)
default:
data = "{}"
}
return &data
}
func DetectCardType(body string) *db.CardType {
if TemplateSlotRe.MatchString(body) {
ct := db.CardTemplate
return &ct
}
if strings.Contains(body, "chose:") || strings.Contains(body, "why:") {
ct := db.CardDecision
return &ct
}
if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") {
ct := db.CardChecklist
return &ct
}
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
ct := db.CardLink
return &ct
}
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package carddata
import (
"encoding/json"
"testing"
"github.com/lerko/nib/internal/db"
)
func TestGenerateCardData_Snippet(t *testing.T) {
data := GenerateCardData(db.CardSnippet, "some snippet")
if data == nil || *data != "{}" {
t.Errorf("snippet should produce {}, got %v", data)
}
}
func TestGenerateCardData_Template(t *testing.T) {
data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
if data == nil {
t.Fatal("expected non-nil data")
}
var parsed struct {
Slots []struct {
Name string `json:"name"`
Default string `json:"default"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Slots) != 2 {
t.Fatalf("expected 2 slots, got %d", len(parsed.Slots))
}
if parsed.Slots[0].Name != "host" {
t.Errorf("first slot: %q", parsed.Slots[0].Name)
}
if parsed.Slots[1].Name != "env" {
t.Errorf("second slot: %q", parsed.Slots[1].Name)
}
}
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
var parsed struct {
Slots []struct {
Name string `json:"name"`
} `json:"slots"`
}
json.Unmarshal([]byte(*data), &parsed)
if len(parsed.Slots) != 1 {
t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots))
}
}
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
data := GenerateCardData(db.CardTemplate, "no placeholders here")
var parsed struct {
Slots []struct {
Name string `json:"name"`
} `json:"slots"`
}
json.Unmarshal([]byte(*data), &parsed)
if len(parsed.Slots) != 0 {
t.Errorf("expected empty slots, got %d", len(parsed.Slots))
}
}
func TestGenerateCardData_Checklist(t *testing.T) {
body := "[ ] step one\n[x] step two\n[ ] step three"
data := GenerateCardData(db.CardChecklist, body)
if data == nil {
t.Fatal("expected non-nil data")
}
var parsed struct {
Steps []struct {
Text string `json:"text"`
Done bool `json:"done"`
} `json:"steps"`
}
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Steps) != 3 {
t.Fatalf("expected 3 steps, got %d", len(parsed.Steps))
}
if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done {
t.Errorf("step 0: %+v", parsed.Steps[0])
}
if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done {
t.Errorf("step 1: %+v", parsed.Steps[1])
}
}
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
var parsed struct {
Steps []struct {
Text string `json:"text"`
Done bool `json:"done"`
} `json:"steps"`
}
json.Unmarshal([]byte(*data), &parsed)
if len(parsed.Steps) != 1 {
t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps))
}
if parsed.Steps[0].Text != "no checkbox syntax" {
t.Errorf("fallback step text: %q", parsed.Steps[0].Text)
}
}
func TestGenerateCardData_Decision(t *testing.T) {
data := GenerateCardData(db.CardDecision, "which db?")
var parsed struct {
Chose string `json:"chose"`
Why string `json:"why"`
Rejected []string `json:"rejected"`
}
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
t.Fatal(err)
}
if parsed.Chose != "" || parsed.Why != "" {
t.Error("decision fields should start empty")
}
if len(parsed.Rejected) != 0 {
t.Error("rejected should start empty")
}
}
func TestGenerateCardData_Link(t *testing.T) {
data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
var parsed struct {
URL string `json:"url"`
}
json.Unmarshal([]byte(*data), &parsed)
if parsed.URL != "https://example.com/path" {
t.Errorf("url: %q", parsed.URL)
}
}
func TestGenerateCardData_LinkNoURL(t *testing.T) {
data := GenerateCardData(db.CardLink, "no url here")
var parsed struct {
URL string `json:"url"`
}
json.Unmarshal([]byte(*data), &parsed)
if parsed.URL != "" {
t.Errorf("expected empty url, got %q", parsed.URL)
}
}