diff --git a/internal/tui/adrsplit/model.go b/internal/tui/adrsplit/model.go new file mode 100644 index 0000000..2e286d8 --- /dev/null +++ b/internal/tui/adrsplit/model.go @@ -0,0 +1,759 @@ +// Package adrsplit implements the direct-runner ADR-to-stories review overlay. +package adrsplit + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "unicode" + "unicode/utf8" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const ( + maxADRBytes = 64 << 10 + defaultMax = 8 + maxStories = 20 + splitMaxTokens = 8192 +) + +var errADRTooLarge = errors.New("ADR is over 64 KiB") + +// Runner is the shared direct-store AI runner. The overlay deliberately has +// no HTTP-shaped seam: it invokes the same package as the server adapters. +type Runner interface { + RunSkill(context.Context, string, ai.Scope, string, string, int, int64) (ai.RunResult, error) +} + +// Store is the one direct SQLite write the review step needs. +type Store interface { + AddTask(string, board.Task) (board.Task, error) +} + +type stage uint8 + +const ( + stageInput stage = iota + stageReview +) + +type sourceMode uint8 + +const ( + sourcePaste sourceMode = iota + sourceFile +) + +type storyRow struct { + draft ai.Draft + include bool + title textinput.Model + prio int + effort string + created bool + err string +} + +type fileLoadedMsg struct { + session uint64 + generation uint64 + text string + err error +} + +type splitCompletedMsg struct { + session uint64 + generation uint64 + run ai.RunResult + err error +} + +type cardAddedMsg struct { + session uint64 + generation uint64 + row int + task board.Task + err error +} + +// Model owns the source, asynchronous split, review edits, and sequential +// batch write. Every result is scoped to both the overlay session and its +// operation generation so a cancelled or reopened dialog cannot be mutated. +type Model struct { + store Store + runner Runner + user string + ctx context.Context + + open bool + stage stage + source sourceMode + session uint64 + generation uint64 + focus string + guardClose bool + operation string + cancel context.CancelFunc + changed bool + + adr textarea.Model + filePath textinput.Model + max int + dest board.Status + rows []storyRow + + adding bool + addQueue []int + addPosition int + addGeneration uint64 + createdCount int + failedCount int + + status string + statusIsError bool + scroll int +} + +// New creates a closed overlay. Nil dependencies keep the feature unavailable +// in lightweight root-model tests. +func New(st Store, runner Runner, user string, ctx context.Context) Model { + if ctx == nil { + ctx = context.Background() + } + m := Model{store: st, runner: runner, user: user, ctx: ctx} + m.resetInputs() + return m +} + +// Enabled reports whether both the shared runner and direct store are wired. +func (m Model) Enabled() bool { return m.store != nil && m.runner != nil } + +// IsOpen reports whether this overlay owns user input and rendering. +func (m Model) IsOpen() bool { return m.open } + +// Open starts a new isolated overlay session. +func (m *Model) Open() tea.Cmd { + if !m.Enabled() { + return nil + } + m.closeNow() + m.session++ + m.generation++ + m.open, m.stage, m.source = true, stageInput, sourcePaste + m.focus, m.guardClose, m.status, m.statusIsError = "source", false, "", false + m.max, m.dest, m.rows = defaultMax, board.StatusTodo, nil + m.changed, m.scroll = false, 0 + m.resetInputs() + return m.applyFocus() +} + +// Close force-closes the overlay and cancels any in-flight read or AI run. +// A batch write already handed to SQLite is not cancellable; its completion is +// safely ignored after the session advances. +func (m *Model) Close() { + m.closeNow() + m.session++ +} + +func (m *Model) closeNow() { + m.cancelOperation() + m.open, m.guardClose = false, false + m.adding = false + m.addQueue = nil +} + +// ConsumeChanged reports durable creations to the root exactly once. +func (m *Model) ConsumeChanged() bool { + if m.adding { + return false + } + changed := m.changed + m.changed = false + return changed +} + +// IsMessage identifies overlay-owned asynchronous results. +func IsMessage(message tea.Msg) bool { + switch message.(type) { + case fileLoadedMsg, splitCompletedMsg, cardAddedMsg: + return true + default: + return false + } +} + +// Update applies user input and scoped asynchronous results. +func (m *Model) Update(message tea.Msg) tea.Cmd { + if !m.open { + return nil + } + switch msg := message.(type) { + case fileLoadedMsg: + if msg.session != m.session || msg.generation != m.generation || m.operation != "reading file" { + return nil + } + m.completeOperation() + if msg.err != nil { + m.setError(msg.err) + return nil + } + return m.startRun(msg.text) + case splitCompletedMsg: + if msg.session != m.session || msg.generation != m.generation || m.operation != "splitting ADR" { + return nil + } + m.completeOperation() + if msg.err != nil { + m.setError(msg.err) + return nil + } + if len(msg.run.Cards) == 0 { + m.setError(errors.New("the model returned no usable stories")) + return nil + } + m.rows = rowsFromDrafts(msg.run.Cards) + m.stage, m.focus, m.scroll = stageReview, "include:0", 0 + m.status, m.statusIsError = fmt.Sprintf("%d stories ready; review before creating", len(m.rows)), false + if msg.run.Partial { + m.status = fmt.Sprintf("%d partial stories ready; review before creating", len(m.rows)) + } + return m.applyFocus() + case cardAddedMsg: + return m.finishAdd(msg) + case tea.KeyPressMsg: + return m.updateKey(msg) + } + return nil +} + +func (m *Model) updateKey(msg tea.KeyPressMsg) tea.Cmd { + key := msg.String() + if m.operation != "" { + if key == "esc" { + m.cancelOperation() + m.status, m.statusIsError = "split cancelled; source preserved", false + } + return nil + } + if m.adding { + return nil + } + if m.guardClose { + switch key { + case "d", "D": + m.closeNow() + case "esc": + m.guardClose = false + m.status, m.statusIsError = "close cancelled", false + } + return nil + } + if key == "esc" { + m.requestClose() + return nil + } + if key == "tab" { + m.moveFocus(1) + return nil + } + if key == "shift+tab" { + m.moveFocus(-1) + return nil + } + if m.stage == stageInput { + return m.updateInputKey(key, msg) + } + return m.updateReviewKey(key, msg) +} + +func (m *Model) updateInputKey(key string, msg tea.KeyPressMsg) tea.Cmd { + switch m.focus { + case "source": + if key == "left" || key == "h" || key == "right" || key == "l" || activationKey(key) { + if m.source == sourcePaste { + m.source = sourceFile + } else { + m.source = sourcePaste + } + m.focus = m.inputTarget() + return m.applyFocus() + } + case "adr": + var command tea.Cmd + m.adr, command = m.adr.Update(msg) + return command + case "file": + var command tea.Cmd + m.filePath, command = m.filePath.Update(msg) + return command + case "max": + switch key { + case "left", "h", "-": + m.max = max(1, m.max-1) + case "right", "l", "+", "enter", " ", "space": + m.max = min(maxStories, m.max+1) + } + case "cancel": + if activationKey(key) { + m.requestClose() + } + case "split": + if activationKey(key) { + return m.startSplit() + } + } + return nil +} + +func (m *Model) updateReviewKey(key string, msg tea.KeyPressMsg) tea.Cmd { + index, field, ok := parseRowFocus(m.focus) + if ok && index >= 0 && index < len(m.rows) { + row := &m.rows[index] + if row.created { + return nil + } + switch field { + case "include": + if activationKey(key) { + row.include = !row.include + row.err = "" + } + case "title": + var command tea.Cmd + row.title, command = row.title.Update(msg) + row.err = "" + return command + case "prio": + row.prio = cycleInt(row.prio, 1, 4, key) + case "effort": + row.effort = cycleEffort(row.effort, key) + } + return nil + } + switch m.focus { + case "dest": + m.dest = cycleStatus(m.dest, key) + case "back": + if activationKey(key) { + m.stage, m.rows, m.focus = stageInput, nil, "source" + m.status, m.statusIsError = "source preserved", false + return m.applyFocus() + } + case "cancel": + if activationKey(key) { + m.requestClose() + } + case "add": + if activationKey(key) { + return m.startAdd() + } + } + return nil +} + +func (m *Model) startSplit() tea.Cmd { + if m.runner == nil { + m.setError(errors.New("AI runner unavailable")) + return nil + } + if m.source == sourcePaste { + text := m.adr.Value() + if strings.TrimSpace(text) == "" { + m.setError(errors.New("paste an ADR first")) + return nil + } + if len([]byte(text)) > maxADRBytes { + m.setError(errADRTooLarge) + return nil + } + return m.startRun(text) + } + path := strings.TrimSpace(m.filePath.Value()) + if path == "" { + m.setError(errors.New("file path required")) + return nil + } + m.generation++ + generation, session := m.generation, m.session + ctx, cancel := context.WithCancel(m.ctx) + m.cancel, m.operation = cancel, "reading file" + m.status, m.statusIsError = "reading ADR file...", false + return func() tea.Msg { + text, err := readADRFile(ctx, path) + return fileLoadedMsg{session: session, generation: generation, text: text, err: err} + } +} + +func (m *Model) startRun(text string) tea.Cmd { + m.generation++ + generation, session, maximum := m.generation, m.session, m.max + ctx, cancel := context.WithCancel(m.ctx) + m.cancel, m.operation = cancel, "splitting ADR" + m.status, m.statusIsError = "splitting ADR...", false + return func() tea.Msg { + run, err := m.runner.RunSkill(ctx, m.user, ai.ScopeReadOnly, "adr-split", text, maximum, splitMaxTokens) + return splitCompletedMsg{session: session, generation: generation, run: run, err: err} + } +} + +func (m *Model) cancelOperation() { + if m.cancel != nil { + m.cancel() + } + m.cancel = nil + if m.operation != "" { + m.generation++ + } + m.operation = "" +} + +func (m *Model) completeOperation() { + if m.cancel != nil { + m.cancel() + } + m.cancel = nil + m.operation = "" +} + +func (m *Model) startAdd() tea.Cmd { + m.addQueue = m.addQueue[:0] + m.createdCount, m.failedCount = 0, 0 + for i := range m.rows { + row := &m.rows[i] + if !row.include || row.created { + continue + } + row.err = "" + if strings.TrimSpace(row.title.Value()) == "" { + row.err = "title required" + m.failedCount++ + continue + } + m.addQueue = append(m.addQueue, i) + } + if len(m.addQueue) == 0 { + if m.failedCount > 0 { + m.status, m.statusIsError = "no valid selected stories; fix the reported rows", true + } else { + m.status, m.statusIsError = "select at least one story", true + } + return nil + } + m.adding, m.addPosition = true, 0 + m.addGeneration++ + m.status, m.statusIsError = fmt.Sprintf("creating card 1 of %d...", len(m.addQueue)), false + return m.addNext() +} + +func (m *Model) addNext() tea.Cmd { + rowIndex := m.addQueue[m.addPosition] + task := taskFromRow(m.rows[rowIndex], m.dest) + session, generation := m.session, m.addGeneration + return func() tea.Msg { + created, err := m.store.AddTask(m.user, task) + return cardAddedMsg{session: session, generation: generation, row: rowIndex, task: created, err: err} + } +} + +func (m *Model) finishAdd(msg cardAddedMsg) tea.Cmd { + if msg.session != m.session || msg.generation != m.addGeneration || !m.adding || + m.addPosition >= len(m.addQueue) || msg.row != m.addQueue[m.addPosition] { + return nil + } + row := &m.rows[msg.row] + if msg.err != nil { + row.err = safeError(msg.err) + m.failedCount++ + } else { + row.err, row.created, row.include = "", true, false + m.createdCount++ + m.changed = true + } + m.addPosition++ + if m.addPosition < len(m.addQueue) { + m.status = fmt.Sprintf("creating card %d of %d...", m.addPosition+1, len(m.addQueue)) + return m.addNext() + } + m.adding = false + if m.failedCount > 0 { + m.status = fmt.Sprintf("created %d; %d failed - review rows and retry", m.createdCount, m.failedCount) + m.statusIsError = true + } else { + m.status = fmt.Sprintf("created %d cards", m.createdCount) + m.statusIsError = false + } + return nil +} + +func taskFromRow(row storyRow, status board.Status) board.Task { + checks := make([]board.Check, len(row.draft.Checks)) + for i, check := range row.draft.Checks { + checks[i] = board.Check{Text: check.Text, Done: check.Done} + } + return board.Task{ + Title: sanitize(strings.TrimSpace(row.title.Value())), Emoji: row.draft.Emoji, + Desc: row.draft.Desc, Status: status, Prio: row.prio, Due: row.draft.Due, + Effort: row.effort, Tags: append([]string(nil), row.draft.Tags...), Checks: checks, + } +} + +func rowsFromDrafts(drafts []ai.Draft) []storyRow { + rows := make([]storyRow, len(drafts)) + for i, draft := range drafts { + title := textinput.New() + title.Prompt = "" + title.SetWidth(60) + title.SetValue(draft.Title) + rows[i] = storyRow{draft: draft, include: true, title: title, prio: draft.Prio, effort: draft.Effort} + } + return rows +} + +func readADRFile(ctx context.Context, path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("read ADR file: %w", err) + } + if !info.Mode().IsRegular() { + return "", errors.New("ADR path is not a regular file") + } + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("read ADR file: %w", err) + } + defer file.Close() + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = file.Close() + case <-done: + } + }() + data, err := io.ReadAll(io.LimitReader(file, maxADRBytes+1)) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + return "", fmt.Errorf("read ADR file: %w", err) + } + if len(data) > maxADRBytes { + return "", errADRTooLarge + } + if !utf8.Valid(data) { + return "", errors.New("ADR file is not valid UTF-8") + } + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + if strings.TrimSpace(string(data)) == "" { + return "", errors.New("ADR file is empty") + } + return string(data), nil +} + +func (m *Model) resetInputs() { + m.adr = textarea.New() + m.adr.Prompt, m.adr.Placeholder, m.adr.ShowLineNumbers = "", "# ADR 0007: adopt ...", false + m.adr.SetWidth(64) + m.adr.SetHeight(8) + m.filePath = textinput.New() + m.filePath.Prompt, m.filePath.Placeholder = "", "/path/to/decision.md" + m.filePath.SetWidth(64) +} + +func (m *Model) requestClose() { + if m.dirty() { + m.guardClose = true + m.status, m.statusIsError = "reviewed work would be discarded: D discard, Esc stay", true + return + } + m.closeNow() +} + +func (m Model) dirty() bool { + return strings.TrimSpace(m.adr.Value()) != "" || strings.TrimSpace(m.filePath.Value()) != "" || len(m.rows) > 0 +} + +func (m *Model) setError(err error) { + m.status, m.statusIsError = safeError(err), true +} + +func safeError(err error) string { + if err == nil { + return "" + } + var aiErr *ai.Error + message := err.Error() + if errors.As(err, &aiErr) && strings.TrimSpace(aiErr.Message) != "" { + message = aiErr.Message + } + if errors.Is(err, context.Canceled) { + message = "split cancelled" + } + message = sanitize(strings.Join(strings.Fields(message), " ")) + if message == "" { + return "operation failed" + } + runes := []rune(message) + if len(runes) > 180 { + return string(runes[:177]) + "..." + } + return message +} + +func sanitize(value string) string { + value = ansi.Strip(value) + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) +} + +func (m Model) inputTarget() string { + if m.source == sourceFile { + return "file" + } + return "adr" +} + +func (m Model) focusTargets() []string { + if m.stage == stageInput { + return []string{"source", m.inputTarget(), "max", "cancel", "split"} + } + targets := make([]string, 0, len(m.rows)*4+3) + for i, row := range m.rows { + if row.created { + continue + } + for _, field := range []string{"include", "title", "prio", "effort"} { + targets = append(targets, fmt.Sprintf("%s:%d", field, i)) + } + } + return append(targets, "dest", "back", "cancel", "add") +} + +func (m *Model) moveFocus(delta int) { + targets := m.focusTargets() + if len(targets) == 0 { + return + } + index := 0 + for i, target := range targets { + if target == m.focus { + index = i + break + } + } + m.focus = targets[(index+delta+len(targets))%len(targets)] + m.guardClose = false + m.applyFocus() +} + +func (m *Model) applyFocus() tea.Cmd { + m.adr.Blur() + m.filePath.Blur() + for i := range m.rows { + m.rows[i].title.Blur() + } + switch m.focus { + case "adr": + return m.adr.Focus() + case "file": + return m.filePath.Focus() + } + if index, field, ok := parseRowFocus(m.focus); ok && field == "title" && index < len(m.rows) { + return m.rows[index].title.Focus() + } + return nil +} + +func parseRowFocus(value string) (int, string, bool) { + var index int + parts := strings.Split(value, ":") + if len(parts) != 2 { + return 0, "", false + } + if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil { + return 0, "", false + } + return index, parts[0], true +} + +func cycleInt(value, low, high int, key string) int { + switch key { + case "left", "h", "-": + value-- + case "right", "l", "+", "enter", " ", "space": + value++ + default: + return value + } + if value < low { + return high + } + if value > high { + return low + } + return value +} + +func cycleEffort(value, key string) string { + values := []string{"", "S", "M", "L"} + index := 0 + for i, candidate := range values { + if candidate == value { + index = i + } + } + switch key { + case "left", "h", "-": + index = (index - 1 + len(values)) % len(values) + case "right", "l", "+", "enter", " ", "space": + index = (index + 1) % len(values) + default: + return value + } + return values[index] +} + +func cycleStatus(value board.Status, key string) board.Status { + index := 0 + for i, status := range board.Statuses { + if status == value { + index = i + } + } + switch key { + case "left", "h", "-": + index = (index - 1 + len(board.Statuses)) % len(board.Statuses) + case "right", "l", "+", "enter", " ", "space": + index = (index + 1) % len(board.Statuses) + } + return board.Statuses[index] +} + +func activationKey(key string) bool { return key == "enter" || key == " " || key == "space" } + +var _ Store = (*store.Store)(nil) diff --git a/internal/tui/adrsplit/model_test.go b/internal/tui/adrsplit/model_test.go new file mode 100644 index 0000000..8d64711 --- /dev/null +++ b/internal/tui/adrsplit/model_test.go @@ -0,0 +1,519 @@ +package adrsplit + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/board" +) + +type runnerCall struct { + ctx context.Context + user, skill, input string + scope ai.Scope + maxCards int + maxTokens int64 +} + +type fakeRunner struct { + run ai.RunResult + err error + calls []runnerCall +} + +func (r *fakeRunner) RunSkill(ctx context.Context, user string, scope ai.Scope, skill, input string, maxCards int, maxTokens int64) (ai.RunResult, error) { + r.calls = append(r.calls, runnerCall{ctx: ctx, user: user, scope: scope, skill: skill, input: input, maxCards: maxCards, maxTokens: maxTokens}) + return r.run, r.err +} + +type fakeStore struct { + calls []board.Task + errs map[string]error +} + +func (s *fakeStore) AddTask(_ string, task board.Task) (board.Task, error) { + s.calls = append(s.calls, task) + if err := s.errs[task.Title]; err != nil { + return board.Task{}, err + } + task.ID = "id-" + task.Title + return task, nil +} + +func testDraft(title string) ai.Draft { + return ai.Draft{ + Title: title, Emoji: "🧭", Desc: "desc", Prio: 2, Due: "2026-08-31", Effort: "M", + Tags: []string{"tui"}, Checks: []ai.DraftCheck{{Text: "test"}, {Text: "ship", Done: true}}, + } +} + +func newTestModel() (*Model, *fakeStore, *fakeRunner) { + st := &fakeStore{errs: make(map[string]error)} + runner := &fakeRunner{run: ai.RunResult{Cards: []ai.Draft{testDraft("one"), testDraft("two")}}} + m := New(st, runner, "alice", context.Background()) + m.Open() + return &m, st, runner +} + +func commandMsg(t *testing.T, command tea.Cmd) tea.Msg { + t.Helper() + if command == nil { + t.Fatal("command is nil") + } + return command() +} + +func TestAvailabilitySessionsAndCloseLifecycle(t *testing.T) { + disabled := New(nil, nil, "u", nil) + if disabled.Enabled() || disabled.IsOpen() || disabled.Open() != nil || disabled.ConsumeChanged() { + t.Fatal("nil dependencies should keep overlay disabled") + } + if disabled.Update(tea.KeyPressMsg{Code: 'x'}) != nil || IsMessage("plain") { + t.Fatal("closed model or unrelated message was handled") + } + if !IsMessage(fileLoadedMsg{}) || !IsMessage(splitCompletedMsg{}) || !IsMessage(cardAddedMsg{}) { + t.Fatal("async message classifier missed a message") + } + + m, _, _ := newTestModel() + firstSession := m.session + if !m.Enabled() || !m.IsOpen() || m.stage != stageInput || m.source != sourcePaste || m.max != defaultMax || m.dest != board.StatusTodo { + t.Fatalf("open state = %+v", m) + } + m.changed = true + if !m.ConsumeChanged() || m.ConsumeChanged() { + t.Fatal("changed acknowledgement was not exactly once") + } + m.adding, m.changed = true, true + if m.ConsumeChanged() { + t.Fatal("batch exposed refresh before completion") + } + m.adding = false + m.Close() + if m.IsOpen() || m.session <= firstSession { + t.Fatalf("close state open=%v session=%d", m.open, m.session) + } + m.Close() +} + +func TestPasteSplitRunsReadOnlySharedSkillAndBuildsReview(t *testing.T) { + m, _, runner := newTestModel() + m.adr.SetValue("# ADR\n\nChoose the boring thing.") + m.max = 12 + command := m.startSplit() + if m.operation != "splitting ADR" || !strings.Contains(m.status, "splitting") { + t.Fatalf("progress = operation:%q status:%q", m.operation, m.status) + } + message := commandMsg(t, command) + if len(runner.calls) != 1 { + t.Fatalf("runner calls = %d", len(runner.calls)) + } + call := runner.calls[0] + if call.user != "alice" || call.scope != ai.ScopeReadOnly || call.skill != "adr-split" || call.input != m.adr.Value() || call.maxCards != 12 || call.maxTokens != splitMaxTokens { + t.Fatalf("runner call = %+v", call) + } + if next := m.Update(message); next != nil || m.stage != stageReview || len(m.rows) != 2 || m.focus != "include:0" { + t.Fatalf("review state rows=%d focus=%q stage=%d next=%v", len(m.rows), m.focus, m.stage, next) + } + if !errors.Is(call.ctx.Err(), context.Canceled) { + t.Fatalf("completed split context = %v", call.ctx.Err()) + } + if !m.rows[0].include || m.rows[0].title.Value() != "one" || m.rows[0].prio != 2 || m.rows[0].effort != "M" { + t.Fatalf("first row = %+v", m.rows[0]) + } + + runner.run.Partial = true + m.stage, m.source = stageInput, sourcePaste + m.adr.SetValue("# ADR") + m.Update(commandMsg(t, m.startSplit())) + if !strings.Contains(m.status, "partial") { + t.Fatalf("partial status = %q", m.status) + } +} + +func TestSplitValidationErrorsAndStaleCompletions(t *testing.T) { + m, _, runner := newTestModel() + if command := m.startSplit(); command != nil || !m.statusIsError || !strings.Contains(m.status, "paste") { + t.Fatalf("empty paste = command:%v status:%q", command, m.status) + } + m.adr.SetValue(strings.Repeat("x", maxADRBytes+1)) + if command := m.startSplit(); command != nil || !errors.Is(errADRTooLarge, errADRTooLarge) || !strings.Contains(m.status, "64 KiB") { + t.Fatalf("oversize paste = command:%v status:%q", command, m.status) + } + m.runner = nil + m.adr.SetValue("# ADR") + if command := m.startSplit(); command != nil || !strings.Contains(m.status, "unavailable") { + t.Fatalf("runner unavailable = command:%v status:%q", command, m.status) + } + m.runner = runner + + runner.err = &ai.Error{Code: http.StatusBadGateway, Message: "upstream refused", Cause: errors.New("secret detail")} + message := commandMsg(t, m.startSplit()) + m.Update(message) + if m.status != "upstream refused" || !m.statusIsError || strings.Contains(m.status, "secret") { + t.Fatalf("safe runner error = %q", m.status) + } + runner.err = nil + runner.run.Cards = nil + m.Update(commandMsg(t, m.startSplit())) + if !strings.Contains(m.status, "no usable stories") { + t.Fatalf("empty run status = %q", m.status) + } + + m.operation = "splitting ADR" + m.generation = 10 + if command := m.Update(splitCompletedMsg{session: m.session + 1, generation: 10, run: ai.RunResult{Cards: []ai.Draft{testDraft("stale")}}}); command != nil || len(m.rows) != 0 { + t.Fatal("stale session completion mutated overlay") + } + if command := m.Update(splitCompletedMsg{session: m.session, generation: 9, run: ai.RunResult{Cards: []ai.Draft{testDraft("stale")}}}); command != nil || len(m.rows) != 0 { + t.Fatal("stale generation completion mutated overlay") + } +} + +func TestCancellationPreservesSourceAndScopesLateResult(t *testing.T) { + m, _, runner := newTestModel() + m.adr.SetValue("# ADR") + command := m.startSplit() + ctx := m.cancel + _ = ctx + generation := m.generation + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.operation != "" || !strings.Contains(m.status, "preserved") || m.generation == generation { + t.Fatalf("cancel state operation=%q status=%q gen=%d", m.operation, m.status, m.generation) + } + message := commandMsg(t, command) + if !errors.Is(runner.calls[len(runner.calls)-1].ctx.Err(), context.Canceled) { + t.Fatal("runner context was not cancelled") + } + m.Update(message) + if m.stage != stageInput { + t.Fatal("late cancelled result changed stage") + } + + m.operation = "reading file" + m.cancel = func() {} + m.Update(tea.KeyPressMsg{Code: 'x'}) + if m.operation != "reading file" { + t.Fatal("non-Escape input interrupted operation") + } +} + +func TestFileModeReadsBoundedUTF8ThenRunsSplit(t *testing.T) { + m, _, runner := newTestModel() + dir := t.TempDir() + path := filepath.Join(dir, "adr.md") + if err := os.WriteFile(path, []byte("# ADR\nUse SQLite."), 0o600); err != nil { + t.Fatal(err) + } + m.source, m.focus = sourceFile, "file" + m.filePath.SetValue(path) + read := m.startSplit() + if m.operation != "reading file" { + t.Fatalf("file progress = %q", m.operation) + } + run := m.Update(commandMsg(t, read)) + if m.operation != "splitting ADR" || run == nil { + t.Fatalf("post-read operation=%q command=%v", m.operation, run) + } + m.Update(commandMsg(t, run)) + if runner.calls[len(runner.calls)-1].input != "# ADR\nUse SQLite." || m.stage != stageReview { + t.Fatalf("file split input=%q stage=%d", runner.calls[len(runner.calls)-1].input, m.stage) + } + + m.Open() + m.source = sourceFile + if command := m.startSplit(); command != nil || !strings.Contains(m.status, "path required") { + t.Fatalf("empty path = command:%v status:%q", command, m.status) + } + m.filePath.SetValue(filepath.Join(dir, "missing.md")) + m.Update(commandMsg(t, m.startSplit())) + if !m.statusIsError || !strings.Contains(m.status, "read ADR file") { + t.Fatalf("missing file error = %q", m.status) + } + + for name, data := range map[string]struct { + data []byte + want string + }{ + "large": {data: []byte(strings.Repeat("x", maxADRBytes+1)), want: "64 KiB"}, + "bad": {data: []byte{0xff, 0xfe}, want: "UTF-8"}, + "empty": {data: []byte(" \n"), want: "empty"}, + } { + t.Run(name, func(t *testing.T) { + file := filepath.Join(dir, name) + if err := os.WriteFile(file, data.data, 0o600); err != nil { + t.Fatal(err) + } + _, err := readADRFile(context.Background(), file) + if err == nil || !strings.Contains(err.Error(), data.want) { + t.Fatalf("error = %v, want %q", err, data.want) + } + }) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := readADRFile(cancelled, path); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled read = %v", err) + } + if _, err := readADRFile(context.Background(), dir); err == nil { + t.Fatal("reading directory should fail") + } +} + +func TestReviewEditsAndSequentialBatchReportsEveryFailure(t *testing.T) { + m, st, _ := newTestModel() + m.stage = stageReview + m.rows = rowsFromDrafts([]ai.Draft{testDraft("one"), testDraft("two"), testDraft("three")}) + m.focus = "include:0" + m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) + if m.rows[0].include { + t.Fatalf("include toggle did not clear; key=%q parse=%v", tea.KeyPressMsg{Code: tea.KeySpace}.String(), m.focus) + } + m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) + m.focus = "title:0" + m.rows[0].title.SetValue("renamed") + m.applyFocus() + m.Update(tea.KeyPressMsg{Code: '!', Text: "!"}) + if !strings.Contains(m.rows[0].title.Value(), "!") { + t.Fatalf("title edit = %q", m.rows[0].title.Value()) + } + m.focus = "prio:0" + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + m.focus = "effort:0" + m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + m.focus = "dest" + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if m.rows[0].prio != 3 || m.rows[0].effort != "S" || m.dest != board.StatusDoing { + t.Fatalf("review edits prio=%d effort=%q dest=%q", m.rows[0].prio, m.rows[0].effort, m.dest) + } + + m.rows[1].title.SetValue(" ") + st.errs["three"] = errors.New("sqlite\x1b[31m\nrefused") + command := m.startAdd() + if !m.adding || len(m.addQueue) != 2 || m.failedCount != 1 || command == nil { + t.Fatalf("batch start adding=%v queue=%v failed=%d", m.adding, m.addQueue, m.failedCount) + } + command = m.Update(commandMsg(t, command)) + if command == nil || !m.adding || m.ConsumeChanged() { + t.Fatal("batch did not continue sequentially or refreshed early") + } + command = m.Update(commandMsg(t, command)) + if command != nil || m.adding || m.createdCount != 1 || m.failedCount != 2 || !m.ConsumeChanged() || m.ConsumeChanged() { + t.Fatalf("batch finish command=%v adding=%v created=%d failed=%d status=%q", command, m.adding, m.createdCount, m.failedCount, m.status) + } + if len(st.calls) != 2 || st.calls[0].Status != board.StatusDoing || st.calls[0].Prio != 3 || st.calls[0].Effort != "S" || len(st.calls[0].Checks) != 2 { + t.Fatalf("store calls = %+v", st.calls) + } + if !m.rows[0].created || m.rows[0].include || m.rows[2].created || !m.rows[2].include || strings.Contains(m.rows[2].err, "\x1b") || strings.Contains(m.rows[2].err, "\n") { + t.Fatalf("row results first=%+v third=%+v", m.rows[0], m.rows[2]) + } + + m.rows[2].include = false + m.rows[1].include = false + if command := m.startAdd(); command != nil || !strings.Contains(m.status, "select") { + t.Fatalf("empty selection command=%v status=%q", command, m.status) + } + m.rows[1].include = true + if command := m.startAdd(); command != nil || !strings.Contains(m.status, "no valid") { + t.Fatalf("blank-only selection command=%v status=%q", command, m.status) + } +} + +func TestAllSuccessBatchAndStaleWriteMessages(t *testing.T) { + m, _, _ := newTestModel() + m.stage = stageReview + m.rows = rowsFromDrafts([]ai.Draft{testDraft("one")}) + command := m.startAdd() + stale := cardAddedMsg{session: m.session + 1, generation: m.addGeneration, row: 0} + if got := m.Update(stale); got != nil || !m.adding { + t.Fatal("stale session write changed batch") + } + stale.session, stale.generation = m.session, m.addGeneration+1 + m.Update(stale) + stale.generation, stale.row = m.addGeneration, 99 + m.Update(stale) + m.Update(commandMsg(t, command)) + if m.status != "created 1 cards" || m.statusIsError || !m.rows[0].created { + t.Fatalf("success status=%q error=%v row=%+v", m.status, m.statusIsError, m.rows[0]) + } +} + +func TestKeyboardNavigationCloseGuardAndBackPreserveSource(t *testing.T) { + m, _, _ := newTestModel() + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if m.source != sourceFile || m.focus != "file" { + t.Fatalf("source toggle source=%d focus=%q", m.source, m.focus) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if m.focus != "max" { + t.Fatalf("tab focus = %q", m.focus) + } + m.max = 1 + m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.max != 1 { + t.Fatalf("max lower clamp = %d", m.max) + } + m.max = maxStories + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if m.max != maxStories { + t.Fatalf("max upper clamp = %d", m.max) + } + m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab, Mod: tea.ModShift})) + if m.focus != "file" { + t.Fatalf("shift-tab focus = %q", m.focus) + } + m.filePath.SetValue("decision.md") + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if !m.guardClose || !m.open { + t.Fatal("dirty Escape did not guard") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.guardClose == true || !m.open { + t.Fatal("guard Escape did not stay") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m.Update(tea.KeyPressMsg{Code: 'D'}) + if m.open { + t.Fatal("confirmed discard did not close") + } + + m.Open() + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.open { + t.Fatal("clean Escape did not close") + } + m.Open() + m.stage = stageReview + m.rows = rowsFromDrafts([]ai.Draft{testDraft("one")}) + m.focus = "back" + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.stage != stageInput || len(m.rows) != 0 || m.focus != "source" || !strings.Contains(m.status, "preserved") { + t.Fatalf("back state stage=%d rows=%d focus=%q status=%q", m.stage, len(m.rows), m.focus, m.status) + } +} + +func TestReviewKeyboardCyclesAndHelpers(t *testing.T) { + m, _, _ := newTestModel() + m.stage = stageReview + m.rows = rowsFromDrafts([]ai.Draft{testDraft("one")}) + for _, target := range []string{"prio:0", "effort:0", "dest"} { + m.focus = target + m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + m.focus = "cancel" + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.guardClose { + t.Fatal("review close did not guard") + } + m.guardClose = false + m.adding = true + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if !m.open { + t.Fatal("Escape closed an active batch") + } + m.adding = false + + if index, field, ok := parseRowFocus("title:12"); !ok || index != 12 || field != "title" { + t.Fatalf("parsed row = %d %q %v", index, field, ok) + } + for _, invalid := range []string{"title", "title:nope", "a:b:c"} { + if _, _, ok := parseRowFocus(invalid); ok { + t.Fatalf("parsed invalid focus %q", invalid) + } + } + if cycleInt(1, 1, 4, "left") != 4 || cycleInt(4, 1, 4, "right") != 1 || cycleInt(2, 1, 4, "x") != 2 { + t.Fatal("priority cycling failed") + } + if cycleEffort("", "left") != "L" || cycleEffort("L", "right") != "" || cycleEffort("M", "x") != "M" { + t.Fatal("effort cycling failed") + } + if cycleStatus(board.StatusTodo, "left") != board.StatusCancelled || cycleStatus(board.StatusCancelled, "right") != board.StatusTodo || cycleStatus(board.StatusDoing, "x") != board.StatusDoing { + t.Fatal("status cycling failed") + } + if sanitize("ok\x1b[31m\n") != "ok" || safeError(nil) != "" || safeError(errors.New(" \n")) != "operation failed" { + t.Fatal("sanitization helpers failed") + } + long := safeError(errors.New(strings.Repeat("x", 200))) + if len([]rune(long)) != 180 || !strings.HasSuffix(long, "...") { + t.Fatalf("bounded error length = %d", len([]rune(long))) + } + + row := rowsFromDrafts([]ai.Draft{testDraft("one")})[0] + task := taskFromRow(row, board.StatusDone) + row.draft.Tags[0] = "changed" + row.draft.Checks[0].Text = "changed" + if task.Status != board.StatusDone || !reflect.DeepEqual(task.Tags, []string{"tui"}) || task.Checks[0].Text != "test" { + t.Fatalf("task conversion aliased draft: %+v", task) + } +} + +func TestKeyboardActionBranchesAndFocusTargets(t *testing.T) { + m, _, _ := newTestModel() + if command := m.Update(struct{}{}); command != nil { + t.Fatal("unknown message returned a command") + } + if got := m.focusTargets(); !reflect.DeepEqual(got, []string{"source", "adr", "max", "cancel", "split"}) { + t.Fatalf("paste targets = %#v", got) + } + m.focus = "adr" + m.applyFocus() + m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) + if m.adr.Value() != "x" { + t.Fatalf("ADR input = %q", m.adr.Value()) + } + m.source, m.focus = sourceFile, "file" + m.applyFocus() + m.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + if m.filePath.Value() != "p" || m.inputTarget() != "file" { + t.Fatalf("file input=%q target=%q", m.filePath.Value(), m.inputTarget()) + } + if got := m.focusTargets(); !reflect.DeepEqual(got, []string{"source", "file", "max", "cancel", "split"}) { + t.Fatalf("file targets = %#v", got) + } + + m.filePath.SetValue("") + m.adr.SetValue("") + m.focus = "split" + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !strings.Contains(m.status, "path required") { + t.Fatalf("split action status = %q", m.status) + } + m.focus = "cancel" + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.open { + t.Fatal("clean cancel action did not close") + } + + m.Open() + m.stage = stageReview + m.rows = rowsFromDrafts([]ai.Draft{testDraft("one"), testDraft("two")}) + m.rows[1].created = true + targets := m.focusTargets() + if len(targets) != 8 || targets[0] != "include:0" || targets[len(targets)-1] != "add" { + t.Fatalf("review targets = %#v", targets) + } + m.focus = "unknown" + m.moveFocus(1) + if m.focus != "title:0" { + t.Fatalf("unknown current focus advanced to %q", m.focus) + } + m.focus = "add" + if command := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); command == nil || !m.adding { + t.Fatalf("add action command=%v adding=%v", command, m.adding) + } + + if safeError(context.Canceled) != "split cancelled" { + t.Fatalf("cancelled error = %q", safeError(context.Canceled)) + } +} diff --git a/internal/tui/adrsplit/testdata/TestADRSplitInputGolden.golden b/internal/tui/adrsplit/testdata/TestADRSplitInputGolden.golden new file mode 100644 index 0000000..db5d44c --- /dev/null +++ b/internal/tui/adrsplit/testdata/TestADRSplitInputGolden.golden @@ -0,0 +1,25 @@ +╭──────────────────────────────────────────────────────────────────────────╮ +│ SPLIT ADR INTO STORIES │ +│ │ +│ Source: paste left/right │ +│ ADR markdown: │ +│ # ADR 0007 │ +│ │ +│ Use the local store directly. │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ UTF-8 bytes: 41 / 65536 │ +│ Max stories: 8 left/right (1-20) │ +│ │ +│ [ Cancel ] │ +│ > [ Propose stories ] │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ tab navigate | esc close │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/adrsplit/view.go b/internal/tui/adrsplit/view.go new file mode 100644 index 0000000..2fcdb8c --- /dev/null +++ b/internal/tui/adrsplit/view.go @@ -0,0 +1,275 @@ +package adrsplit + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/tui/formview" +) + +const maxPaneWidth = 100 + +// View renders the overlay without its board background. +func (m *Model) View(width, height int) string { + if !m.open { + return "" + } + frame, _, _ := m.frame(width, height) + return lipgloss.Place(max(width, 1), max(height, 1), lipgloss.Center, lipgloss.Center, frame) +} + +// Overlay composes the split review over the current board/detail surface. +func (m *Model) Overlay(background string, width, height int) string { + if !m.open { + return background + } + width, height = max(width, 1), max(height, 1) + frame, paneWidth, paneHeight := m.frame(width, height) + return lipgloss.NewCompositor( + lipgloss.NewLayer(background), + lipgloss.NewLayer(frame).X(max((width-paneWidth)/2, 0)).Y(max((height-paneHeight)/2, 0)).Z(3), + ).Render() +} + +func (m *Model) frame(width, height int) (string, int, int) { + width, height = max(width, 1), max(height, 1) + paneWidth := min(max(width-4, 18), maxPaneWidth, width) + paneHeight := min(max(height-2, 7), height) + innerWidth := max(paneWidth-4, 1) + bodyHeight := max(paneHeight-4, 1) + body := m.bodyLines(innerWidth) + focusLine := focusedLine(body) + maxScroll := max(len(body)-bodyHeight, 0) + if focusLine < m.scroll { + m.scroll = focusLine + } + if focusLine >= m.scroll+bodyHeight { + m.scroll = focusLine - bodyHeight + 1 + } + m.scroll = min(max(m.scroll, 0), maxScroll) + end := min(m.scroll+bodyHeight, len(body)) + visible := make([]string, 0, bodyHeight) + for _, line := range body[m.scroll:end] { + visible = append(visible, fit(line, innerWidth)) + } + for len(visible) < bodyHeight { + visible = append(visible, "") + } + footer := "tab navigate | esc close" + if m.guardClose { + footer = "D discard | esc stay" + } else if m.operation != "" { + footer = m.operation + "... | esc cancel" + } else if m.adding { + footer = m.status + } else if m.status != "" { + prefix := "status: " + if m.statusIsError { + prefix = "error: " + } + footer = prefix + sanitize(m.status) + } + content := strings.Join(visible, "\n") + "\n" + fit(footer, innerWidth) + frame := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 1). + Width(innerWidth). + Height(paneHeight - 2). + Render(content) + frame = fitBlock(frame, width, height) + return frame, lipgloss.Width(frame), lipgloss.Height(frame) +} + +func (m *Model) bodyLines(width int) []string { + if m.stage == stageInput { + return m.inputLines(width) + } + return m.reviewLines(width) +} + +func (m *Model) inputLines(width int) []string { + source := "paste" + if m.source == sourceFile { + source = "file" + } + lines := []string{ + "SPLIT ADR INTO STORIES", + "", + m.choiceLine("source", "Source", source+" left/right"), + } + if m.source == sourcePaste { + lines = append(lines, m.areaBlock("adr", "ADR markdown", m.adr, width, 8)...) + lines = append(lines, fmt.Sprintf(" UTF-8 bytes: %d / %d", len([]byte(m.adr.Value())), maxADRBytes)) + } else { + lines = append(lines, m.inputLine("file", "ADR file", m.filePath, width)) + lines = append(lines, " read is bounded before AI receives the file") + } + lines = append(lines, + m.choiceLine("max", "Max stories", fmt.Sprintf("%d left/right (1-20)", m.max)), + "", + m.actionLine("cancel", "Cancel"), + m.actionLine("split", "Propose stories"), + ) + return lines +} + +func (m *Model) reviewLines(width int) []string { + lines := []string{ + "REVIEW PROPOSED STORIES", + "Nothing is created until Add selected.", + "", + } + for i := range m.rows { + row := &m.rows[i] + mark := " " + if row.include { + mark = "x" + } + if row.created { + mark = "*" + } + lines = append(lines, m.choiceLine(fmt.Sprintf("include:%d", i), fmt.Sprintf("%d", i+1), "["+mark+"] include")) + lines = append(lines, m.inputLine(fmt.Sprintf("title:%d", i), " Title", row.title, width)) + lines = append(lines, + m.choiceLine(fmt.Sprintf("prio:%d", i), " Priority", fmt.Sprintf("%d left/right", row.prio)), + m.choiceLine(fmt.Sprintf("effort:%d", i), " Effort", effortName(row.effort)+" left/right"), + ) + if row.created { + lines = append(lines, " created") + } else if row.err != "" { + lines = append(lines, " error: "+sanitize(row.err)) + } + lines = append(lines, "") + } + lines = append(lines, + m.choiceLine("dest", "Destination", statusName(m.dest)+" left/right"), + "", + m.actionLine("back", "Back to source"), + m.actionLine("cancel", "Close"), + m.actionLine("add", fmt.Sprintf("Add selected (%d)", m.selectedCount())), + ) + return lines +} + +func (m Model) selectedCount() int { + count := 0 + for _, row := range m.rows { + if row.include && !row.created && strings.TrimSpace(row.title.Value()) != "" { + count++ + } + } + return count +} + +func (m Model) inputLine(target, label string, input textinput.Model, width int) string { + prefix := " " + if m.focus == target { + prefix = "> " + } + available := max(width-len([]rune(prefix+label+": ")), 1) + return prefix + label + ": " + inputDisplay(input, m.focus == target, available) +} + +func (m Model) choiceLine(target, label, value string) string { + prefix := " " + if m.focus == target { + prefix = "> " + } + return prefix + label + ": " + sanitize(value) +} + +func (m Model) actionLine(target, label string) string { + prefix := " [ " + if m.focus == target { + prefix = "> [ " + } + return prefix + sanitize(label) + " ]" +} + +func (m Model) areaBlock(target, label string, area textarea.Model, width, rows int) []string { + prefix := " " + if m.focus == target { + prefix = "> " + } + lines := []string{prefix + label + ":"} + return append(lines, areaDisplay(area, m.focus == target, width, rows)...) +} + +func inputDisplay(input textinput.Model, focused bool, width int) string { + return formview.Input(input, focused, width, sanitize, cursorViewport) +} + +func areaDisplay(area textarea.Model, focused bool, width, rows int) []string { + return formview.Area(area, focused, width, rows, sanitize, cursorViewport) +} + +func cursorViewport(value string, position, width int) string { + if width <= 0 { + return "" + } + if width == 1 { + return "|" + } + runes := []rune(value) + position = min(max(position, 0), len(runes)) + start := max(position-width+2, 0) + end := min(start+width-1, len(runes)) + visible := append([]rune(nil), runes[start:end]...) + cursor := position - start + if cursor >= len(visible) { + visible = append(visible, '|') + } else { + visible = append(visible[:cursor], append([]rune{'|'}, visible[cursor:]...)...) + } + return ansi.Truncate(string(visible), width, "") +} + +func focusedLine(lines []string) int { + for i, line := range lines { + if strings.HasPrefix(line, ">") { + return i + } + } + return 0 +} + +func statusName(status board.Status) string { + switch status { + case board.StatusTodo: + return "To Do" + case board.StatusDoing: + return "Doing" + case board.StatusDone: + return "Done" + case board.StatusCancelled: + return "Cancelled" + default: + return sanitize(string(status)) + } +} + +func effortName(value string) string { + if value == "" { + return "none" + } + return value +} + +func fit(line string, width int) string { return ansi.Truncate(line, max(width, 0), "") } + +func fitBlock(block string, width, height int) string { + lines := strings.Split(block, "\n") + if len(lines) > height { + lines = lines[:height] + } + for i := range lines { + lines[i] = fit(lines[i], width) + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/adrsplit/view_test.go b/internal/tui/adrsplit/view_test.go new file mode 100644 index 0000000..73e2b9d --- /dev/null +++ b/internal/tui/adrsplit/view_test.go @@ -0,0 +1,129 @@ +package adrsplit + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/board" +) + +func normalizedView(model *Model, width, height int) string { + lines := strings.Split(ansi.Strip(model.View(width, height)), "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + return strings.Trim(strings.Join(lines, "\n"), "\n") + "\n" +} + +func TestADRSplitInputGolden(t *testing.T) { + m, _, _ := newTestModel() + m.adr.SetValue("# ADR 0007\n\nUse the local store directly.") + m.focus = "split" + golden.RequireEqual(t, normalizedView(m, 84, 28)) +} + +func TestViewsCoverFileReviewProgressErrorsAndNarrowTerminals(t *testing.T) { + closed := Model{} + if closed.View(80, 24) != "" || closed.Overlay("board", 80, 24) != "board" { + t.Fatal("closed overlay rendered") + } + + m, _, _ := newTestModel() + m.focus = "adr" + if got := ansi.Strip(m.View(60, 16)); !strings.Contains(got, "ADR markdown") { + t.Fatalf("focused paste view missing:\n%s", got) + } + m.source, m.focus = sourceFile, "file" + m.filePath.SetValue("bad\x1b[31m/path.md") + m.status, m.statusIsError = "read\x1b[32m\nfailed", true + fileView := ansi.Strip(m.View(72, 18)) + if !strings.Contains(fileView, "ADR file") || !strings.Contains(fileView, "bounded") || strings.Contains(fileView, "\x1b") || strings.Contains(fileView, "\nfailed") { + t.Fatalf("unsafe or incomplete file view:\n%s", fileView) + } + + m.operation = "splitting ADR" + if got := ansi.Strip(m.View(30, 8)); !strings.Contains(got, "splitting ADR") || len(strings.Split(got, "\n")) > 8 { + t.Fatalf("narrow progress view:\n%s", got) + } + m.operation, m.guardClose = "", true + if got := ansi.Strip(m.View(50, 12)); !strings.Contains(got, "D discard") { + t.Fatalf("guard footer missing:\n%s", got) + } + + m.guardClose, m.stage = false, stageReview + m.rows = rowsFromDrafts([]ai.Draft{testDraft("one"), testDraft("two"), testDraft("three")}) + m.rows[0].created = true + m.rows[0].include = false + m.rows[1].err = "sqlite\x1b[31m\nrefused" + m.rows[2].include = false + m.focus, m.dest = "title:1", board.StatusCancelled + m.applyFocus() + review := ansi.Strip(m.View(92, 34)) + for _, want := range []string{"REVIEW PROPOSED STORIES", "created", "error: sqliterefused", "Cancelled", "Add selected (1)"} { + if !strings.Contains(review, want) { + t.Errorf("review missing %q:\n%s", want, review) + } + } + if strings.Contains(review, "\x1b") || strings.Contains(review, "\nrefused") { + t.Fatalf("control reached review:\n%s", review) + } + background := strings.Repeat("b", 40) + if overlay := ansi.Strip(m.Overlay(background, 40, 10)); overlay == background || len(strings.Split(overlay, "\n")) > 10 { + t.Fatalf("overlay did not compose or fit:\n%s", overlay) + } + + m.adding, m.status = true, "creating card 1 of 2..." + if got := ansi.Strip(m.View(60, 16)); !strings.Contains(got, "creating card") { + t.Fatalf("batch progress footer missing:\n%s", got) + } +} + +func TestViewHelpersCoverCursorPlaceholdersAndLabels(t *testing.T) { + input := textinput.New() + input.Placeholder = "placeholder" + if got := inputDisplay(input, false, 5); got != "place" { + t.Fatalf("truncated placeholder = %q", got) + } + input.SetValue("abcdef") + input.SetCursor(3) + if got := inputDisplay(input, true, 4); !strings.Contains(got, "|") || ansi.StringWidth(got) > 4 { + t.Fatalf("focused input = %q", got) + } + if cursorViewport("abc", 1, 0) != "" || cursorViewport("abc", 1, 1) != "|" || !strings.Contains(cursorViewport("abcdef", 6, 4), "|") { + t.Fatal("cursor viewport edge branches failed") + } + + area := textarea.New() + area.Placeholder = "line one\nline two" + if got := areaDisplay(area, false, 20, 3); len(got) != 3 || !strings.Contains(got[0], "line one") { + t.Fatalf("placeholder area = %#v", got) + } + area.SetValue("one\ntwo\nthree") + if got := areaDisplay(area, true, 8, 2); len(got) != 2 || !strings.Contains(strings.Join(got, ""), "|") { + t.Fatalf("focused area = %#v", got) + } + + if focusedLine([]string{"a", "> b"}) != 1 || focusedLine([]string{"a"}) != 0 { + t.Fatal("focused-line helper failed") + } + for status, want := range map[board.Status]string{ + board.StatusTodo: "To Do", board.StatusDoing: "Doing", board.StatusDone: "Done", + board.StatusCancelled: "Cancelled", board.Status("bad\x1b[31m"): "bad", + } { + if got := statusName(status); got != want { + t.Errorf("statusName(%q) = %q, want %q", status, got, want) + } + } + if effortName("") != "none" || effortName("L") != "L" || fit("abcdef", 3) != "abc" { + t.Fatal("small rendering helpers failed") + } + if got := fitBlock("one\ntwo\nthree", 2, 2); got != "on\ntw" { + t.Fatalf("fitBlock = %q", got) + } +} diff --git a/internal/tui/board_view.go b/internal/tui/board_view.go index e2e88e1..1bb6699 100644 --- a/internal/tui/board_view.go +++ b/internal/tui/board_view.go @@ -300,7 +300,7 @@ func (m Model) renderBoard() (string, []boardHit) { return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits } if m.settingsNew != nil { - footer := settingsBoardFooter(state, cancelled, m.editor.Enabled(), width) + footer := settingsBoardFooter(state, cancelled, m.editor.Enabled(), m.adr.Enabled(), width) return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits } footer := fitLine(state+" | "+help, width) @@ -382,7 +382,7 @@ func (m Model) renderFilterBar(width int) (string, []boardHit) { }, "\n"), hits } -func settingsBoardFooter(state, cancelled string, editorEnabled bool, width int) string { +func settingsBoardFooter(state, cancelled string, editorEnabled, adrEnabled bool, width int) string { candidates := [][]string{ {"s settings", "j/k cards", "h/l/tab columns", "1-4 jump", "c cancelled:" + cancelled, "q quit"}, {"s settings", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"}, @@ -402,6 +402,11 @@ func settingsBoardFooter(state, cancelled string, editorEnabled bool, width int) {"s settings", "n new", "e edit", "j/k cards", "h/l/tab columns", "q quit"}, }, candidates...) } + if adrEnabled { + for i := range candidates { + candidates[i] = append([]string{"a split ADR"}, candidates[i]...) + } + } minimumStateWidth := min(ansi.StringWidth(state), 5) for _, candidate := range candidates { help := strings.Join(candidate, " | ") diff --git a/internal/tui/cardeditor/model.go b/internal/tui/cardeditor/model.go index ff99986..4e69f23 100644 --- a/internal/tui/cardeditor/model.go +++ b/internal/tui/cardeditor/model.go @@ -2,6 +2,8 @@ package cardeditor import ( + "context" + "encoding/json" "errors" "fmt" "sort" @@ -14,15 +16,23 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" + "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/board" "github.com/RandomCodeSpace/kb/internal/store" ) const ( - similarDelay = 400 * time.Millisecond - similarLimit = 10 + similarDelay = 400 * time.Millisecond + similarLimit = 10 + draftMaxTokens = 4096 ) +// SkillRunner is the shared direct-store AI runner used by the editor. The +// narrow interface keeps model tests deterministic without an HTTP adapter. +type SkillRunner interface { + RunSkill(context.Context, string, ai.Scope, string, string, int, int64) (ai.RunResult, error) +} + // Store is the direct SQLite projection used by the editor. It deliberately // mirrors the store package instead of introducing an HTTP-shaped adapter. type Store interface { @@ -65,6 +75,13 @@ type saveCompletedMsg struct { err error } +type draftCompletedMsg struct { + session uint64 + generation uint64 + draft ai.Draft + err error +} + type snapshot struct { title, emoji, desc, due, effort, checks string prio int @@ -78,9 +95,11 @@ type editedFields struct { // Model owns every mutable editor field and all asynchronous generations. type Model struct { - store Store - user string - now func() time.Time + store Store + user string + now func() time.Time + runner SkillRunner + ctx context.Context open bool mode mode @@ -95,17 +114,21 @@ type Model struct { saving bool savedTaskID string stale bool - - title textinput.Model - emoji textinput.Model - desc textarea.Model - due textinput.Model - label textinput.Model - checks textarea.Model - prio int - effort string - blocked bool - tags []string + drafting bool + draftCancel context.CancelFunc + draftGen uint64 + + title textinput.Model + emoji textinput.Model + desc textarea.Model + due textinput.Model + label textinput.Model + checks textarea.Model + draftPrompt textarea.Model + prio int + effort string + blocked bool + tags []string labels []string labelsOpen bool @@ -128,17 +151,32 @@ type Model struct { // New creates a closed editor. A nil store keeps the feature unavailable in // lightweight root-model tests. func New(st Store, user string) Model { - m := Model{store: st, user: user, now: time.Now} + m := Model{store: st, user: user, now: time.Now, ctx: context.Background()} m.resetInputs() return m } +// SetAIRunner wires the shared runner and the root program context. A nil +// runner hides the draft controls while leaving ordinary editing available. +func (m *Model) SetAIRunner(runner SkillRunner, ctx context.Context) { + m.cancelDraft() + m.runner = runner + if ctx == nil { + ctx = context.Background() + } + m.ctx = ctx +} + // Enabled reports whether the root has a writable direct-store backend. func (m Model) Enabled() bool { return m.store != nil } // IsOpen reports whether the overlay owns input and rendering. func (m Model) IsOpen() bool { return m.open } +// CancelAsync stops work whose result no longer has a live program to receive +// it. It is used by the root shutdown path and is otherwise idempotent. +func (m *Model) CancelAsync() { m.cancelDraft() } + // TaskID returns the edited durable task id, or empty for add/closed modes. func (m Model) TaskID() string { if !m.open || m.mode != modeEdit { @@ -163,7 +201,7 @@ func (m *Model) ConsumeSaved() (string, bool) { // implementation messages into the root package. func IsMessage(message tea.Msg) bool { switch message.(type) { - case labelsLoadedMsg, similarDebounceMsg, similarLoadedMsg, saveCompletedMsg: + case labelsLoadedMsg, similarDebounceMsg, similarLoadedMsg, saveCompletedMsg, draftCompletedMsg: return true default: return false @@ -192,10 +230,11 @@ func (m *Model) OpenEdit(task board.Task) tea.Cmd { } func (m *Model) openForm(nextMode mode, task board.Task) { + m.cancelDraft() m.session++ m.mode, m.base, m.canonical, m.status = nextMode, task, task, task.Status m.canonicalFound = nextMode == modeEdit - m.open, m.guardClose, m.saving, m.stale = true, false, false, false + m.open, m.guardClose, m.saving, m.stale, m.drafting = true, false, false, false, false m.savedTaskID = "" m.labels, m.similar = nil, nil m.dismissed = make(map[string]struct{}) @@ -205,6 +244,7 @@ func (m *Model) openForm(nextMode mode, task board.Task) { m.similarLoading, m.similarQuery, m.similarExclusions = false, "", "" m.similarCache = make(map[string][]store.SimilarHit) m.statusMessage, m.statusIsError, m.scroll = "", false, 0 + m.draftGen++ m.resetInputs() m.applyTask(task) m.focus = "title" @@ -219,6 +259,7 @@ func (m *Model) resetInputs() { m.label = editorInput("label or scope::value") m.desc = editorArea("Description", 4) m.checks = editorArea("one per line; prefix x when done", 4) + m.draftPrompt = editorArea("Describe what to draft or change", 3) m.prio = 3 m.effort = "" m.blocked = false @@ -277,6 +318,7 @@ func (m *Model) Refresh(task board.Task, found bool) tea.Cmd { } if !found { m.canonicalFound = false + m.cancelDraft() m.open = false return nil } @@ -337,8 +379,25 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { } m.base, m.canonical, m.canonicalFound = msg.task, msg.task, true m.initial = m.currentSnapshot() + m.cancelDraft() m.savedTaskID, m.open = msg.task.ID, false return nil + case draftCompletedMsg: + if msg.session != m.session || msg.generation != m.draftGen { + return nil + } + if m.draftCancel != nil { + m.draftCancel() + } + m.drafting, m.draftCancel = false, nil + if msg.err != nil { + m.statusMessage = "AI draft failed: " + safeError(msg.err) + m.statusIsError = true + return nil + } + m.applyDraft(msg.draft) + m.statusMessage, m.statusIsError = "AI draft applied; review before saving", false + return m.scheduleSimilar() case tea.KeyPressMsg: return m.updateKey(msg) } @@ -347,12 +406,20 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { func (m *Model) updateKey(msg tea.KeyPressMsg) tea.Cmd { key := msg.String() + if m.drafting { + if key == "esc" { + m.cancelDraft() + m.statusMessage, m.statusIsError = "AI draft cancelled", false + } + return nil + } if m.saving { return nil } if m.guardClose { switch key { case "d", "D": + m.cancelDraft() m.open, m.guardClose = false, false case "esc": m.guardClose = false @@ -386,6 +453,11 @@ func (m *Model) updateKey(msg tea.KeyPressMsg) tea.Cmd { return nil } switch m.focus { + case "ai-draft": + if key == "enter" || key == " " || key == "space" { + return m.startDraft() + } + return nil case "prio": return m.updatePriority(key) case "effort": @@ -417,6 +489,92 @@ func (m *Model) updateKey(msg tea.KeyPressMsg) tea.Cmd { return m.updateFocusedInput(msg) } +func (m *Model) startDraft() tea.Cmd { + if m.runner == nil || m.drafting { + return nil + } + prompt := strings.TrimSpace(m.draftPrompt.Value()) + if prompt == "" { + m.statusMessage, m.statusIsError = "AI draft request is required", true + return nil + } + input := "Create a new kanban card for this request:\n" + prompt + if m.mode == modeEdit { + input = "Update the kanban card according to this request:\n" + prompt + if current, err := m.currentCardJSON(); err == nil { + input += "\n\nCurrent card JSON:\n" + string(current) + } + } + m.draftGen++ + generation, session := m.draftGen, m.session + ctx, cancel := context.WithCancel(m.ctx) + m.draftCancel, m.drafting = cancel, true + m.statusMessage, m.statusIsError = "drafting card...", false + return func() tea.Msg { + run, err := m.runner.RunSkill(ctx, m.user, ai.ScopeReadOnly, "story-draft", input, 1, draftMaxTokens) + if err == nil && len(run.Cards) == 0 { + err = errors.New("the model returned no usable card") + } + var draft ai.Draft + if len(run.Cards) > 0 { + draft = run.Cards[0] + } + return draftCompletedMsg{session: session, generation: generation, draft: draft, err: err} + } +} + +func (m *Model) currentCardJSON() ([]byte, error) { + checks := textToChecks(m.checks.Value()) + type wireCheck struct { + Text string `json:"text"` + Done bool `json:"done"` + } + wireChecks := make([]wireCheck, len(checks)) + for i, check := range checks { + wireChecks[i] = wireCheck{Text: check.Text, Done: check.Done} + } + return json.Marshal(struct { + Title string `json:"title"` + Desc string `json:"desc"` + Prio int `json:"prio"` + Due string `json:"due"` + Effort string `json:"effort"` + Tags []string `json:"tags"` + Checks []wireCheck `json:"checks"` + }{ + Title: strings.TrimSpace(m.title.Value()), Desc: strings.TrimSpace(m.desc.Value()), + Prio: m.prio, Due: strings.TrimSpace(m.due.Value()), Effort: m.effort, + Tags: append([]string(nil), m.tags...), Checks: wireChecks, + }) +} + +func (m *Model) applyDraft(draft ai.Draft) { + if draft.Title != "" { + m.title.SetValue(draft.Title) + } + m.emoji.SetValue(draft.Emoji) + m.desc.SetValue(draft.Desc) + m.prio, m.effort = draft.Prio, draft.Effort + m.due.SetValue(draft.Due) + m.tags = append([]string(nil), draft.Tags...) + checks := make([]board.Check, len(draft.Checks)) + for i, check := range draft.Checks { + checks[i] = board.Check{Text: check.Text, Done: check.Done} + } + m.checks.SetValue(checksToText(checks)) +} + +func (m *Model) cancelDraft() { + if m.draftCancel != nil { + m.draftCancel() + } + m.draftCancel = nil + if m.drafting { + m.draftGen++ + } + m.drafting = false +} + func (m *Model) requestClose() { if m.Dirty() { m.guardClose = true @@ -424,6 +582,7 @@ func (m *Model) requestClose() { m.statusIsError = true return } + m.cancelDraft() m.open = false } @@ -554,6 +713,8 @@ func (m *Model) updateFocusedInput(msg tea.Msg) tea.Cmd { m.due, cmd = m.due.Update(msg) case "checks": m.checks, cmd = m.checks.Update(msg) + case "ai-prompt": + m.draftPrompt, cmd = m.draftPrompt.Update(msg) } return cmd } @@ -576,7 +737,11 @@ func (m *Model) moveFocus(delta int) { } func (m Model) focusTargets() []string { - targets := []string{"title", "emoji", "desc", "prio", "due", "effort", "blocked", "labels", "checks"} + targets := make([]string, 0, 16) + if m.runner != nil { + targets = append(targets, "ai-prompt", "ai-draft") + } + targets = append(targets, "title", "emoji", "desc", "prio", "due", "effort", "blocked", "labels", "checks") if !m.dismissedAll { for _, hit := range m.visibleSimilar() { targets = append(targets, "similar:"+similarKey(hit)) @@ -595,6 +760,7 @@ func (m *Model) applyFocus() tea.Cmd { m.due.Blur() m.label.Blur() m.checks.Blur() + m.draftPrompt.Blur() switch m.focus { case "title": return m.title.Focus() @@ -609,6 +775,8 @@ func (m *Model) applyFocus() tea.Cmd { return m.label.Focus() case "checks": return m.checks.Focus() + case "ai-prompt": + return m.draftPrompt.Focus() } return nil } diff --git a/internal/tui/cardeditor/model_test.go b/internal/tui/cardeditor/model_test.go index 5d7260b..fc3ba1c 100644 --- a/internal/tui/cardeditor/model_test.go +++ b/internal/tui/cardeditor/model_test.go @@ -1,7 +1,10 @@ package cardeditor import ( + "context" + "encoding/json" "errors" + "reflect" "strings" "testing" "time" @@ -9,6 +12,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" + "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/board" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -26,6 +30,25 @@ type similarQuery struct { limit int } +type draftRunnerCall struct { + ctx context.Context + user, skill, input string + scope ai.Scope + maxCards int + maxTokens int64 +} + +type fakeDraftRunner struct { + run ai.RunResult + err error + calls []draftRunnerCall +} + +func (r *fakeDraftRunner) RunSkill(ctx context.Context, user string, scope ai.Scope, skill, input string, maxCards int, maxTokens int64) (ai.RunResult, error) { + r.calls = append(r.calls, draftRunnerCall{ctx: ctx, user: user, scope: scope, skill: skill, input: input, maxCards: maxCards, maxTokens: maxTokens}) + return r.run, r.err +} + func (s *faultStore) AddTask(user string, task board.Task) (board.Task, error) { if s.addErr != nil { return board.Task{}, s.addErr @@ -875,3 +898,155 @@ func TestFocusTargetsHelpersAndCleanCloseBranches(t *testing.T) { t.Fatal("multi command batch failed") } } + +func TestAIDraftCreateUsesReadOnlyRunnerAndFillsFormForReview(t *testing.T) { + runner := &fakeDraftRunner{run: ai.RunResult{Cards: []ai.Draft{{ + Title: "Drafted card", Emoji: "🧭", Desc: "generated", Prio: 1, + Due: "2026-08-30", Effort: "L", Tags: []string{"ai", "type::feature"}, + Checks: []ai.DraftCheck{{Text: "review"}, {Text: "ship", Done: true}}, + }}}} + model := New(newTestStore(t), "alice") + model.SetAIRunner(runner, context.Background()) + run(t, &model, model.OpenAdd(board.StatusDoing)) + model.draftPrompt.SetValue("write the release task") + model.focus = "ai-draft" + command := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if command == nil || !model.drafting || model.statusMessage != "drafting card..." { + t.Fatalf("draft start command=%v drafting=%v status=%q", command, model.drafting, model.statusMessage) + } + model.Update(commandMsgForEditor(t, command)) + if len(runner.calls) != 1 { + t.Fatalf("runner calls = %d", len(runner.calls)) + } + call := runner.calls[0] + if call.user != "alice" || call.scope != ai.ScopeReadOnly || call.skill != "story-draft" || call.maxCards != 1 || call.maxTokens != draftMaxTokens || !strings.HasPrefix(call.input, "Create a new kanban card") { + t.Fatalf("runner call = %+v", call) + } + if !errors.Is(call.ctx.Err(), context.Canceled) { + t.Fatalf("completed draft context = %v", call.ctx.Err()) + } + if model.title.Value() != "Drafted card" || model.emoji.Value() != "🧭" || model.desc.Value() != "generated" || model.prio != 1 || model.due.Value() != "2026-08-30" || model.effort != "L" || model.blocked || !reflect.DeepEqual(model.tags, []string{"ai", "type::feature"}) || model.checks.Value() != "review\nx ship" { + t.Fatalf("applied form title=%q emoji=%q desc=%q prio=%d due=%q effort=%q blocked=%v tags=%v checks=%q", model.title.Value(), model.emoji.Value(), model.desc.Value(), model.prio, model.due.Value(), model.effort, model.blocked, model.tags, model.checks.Value()) + } + if !model.IsOpen() || !model.Dirty() || model.drafting || model.statusIsError || !strings.Contains(model.statusMessage, "review") { + t.Fatalf("post-draft open=%v dirty=%v drafting=%v status=%q error=%v", model.open, model.Dirty(), model.drafting, model.statusMessage, model.statusIsError) + } +} + +func TestAIDraftEditCarriesCurrentFormJSONAndPreservesBlocked(t *testing.T) { + runner := &fakeDraftRunner{run: ai.RunResult{Cards: []ai.Draft{{ + Title: "Updated", Desc: "new", Prio: 4, Tags: []string{}, Checks: []ai.DraftCheck{}, + }}}} + model := New(newTestStore(t), "u") + model.SetAIRunner(runner, context.Background()) + run(t, &model, model.OpenEdit(fullEditorTask())) + model.title.SetValue("locally edited") + model.draftPrompt.SetValue("make it smaller") + command := model.startDraft() + model.Update(commandMsgForEditor(t, command)) + if !strings.HasPrefix(runner.calls[0].input, "Update the kanban card") || !strings.Contains(runner.calls[0].input, "Current card JSON") { + t.Fatalf("edit prompt = %q", runner.calls[0].input) + } + jsonText := strings.Split(runner.calls[0].input, "Current card JSON:\n")[1] + var current map[string]any + if err := json.Unmarshal([]byte(jsonText), ¤t); err != nil { + t.Fatal(err) + } + if current["title"] != "locally edited" || current["desc"] != fullEditorTask().Desc || current["prio"] != float64(1) { + t.Fatalf("current JSON = %#v", current) + } + if _, found := current["emoji"]; found { + t.Fatalf("wire current card unexpectedly included emoji: %#v", current) + } + if model.title.Value() != "Updated" || !model.blocked { + t.Fatalf("application title=%q blocked=%v", model.title.Value(), model.blocked) + } +} + +func TestAIDraftCancellationErrorsAndExternalDeleteCannotReviveEditor(t *testing.T) { + runner := &fakeDraftRunner{run: ai.RunResult{Cards: []ai.Draft{{Title: "late", Prio: 3}}}} + model := New(newTestStore(t), "u") + model.SetAIRunner(runner, context.Background()) + run(t, &model, model.OpenEdit(fullEditorTask())) + model.draftPrompt.SetValue("draft") + command := model.startDraft() + generation := model.draftGen + model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.drafting || model.draftGen == generation || model.statusMessage != "AI draft cancelled" { + t.Fatalf("cancel state drafting=%v gen=%d status=%q", model.drafting, model.draftGen, model.statusMessage) + } + message := commandMsgForEditor(t, command) + if !errors.Is(runner.calls[0].ctx.Err(), context.Canceled) { + t.Fatal("draft context was not cancelled") + } + model.Update(message) + if model.title.Value() == "late" { + t.Fatal("cancelled result applied") + } + + model.draftPrompt.SetValue("again") + command = model.startDraft() + model.Refresh(board.Task{}, false) + if model.IsOpen() || model.drafting { + t.Fatalf("external delete open=%v drafting=%v", model.open, model.drafting) + } + model.Update(commandMsgForEditor(t, command)) + if len(runner.calls) != 2 { + t.Fatalf("external delete runner calls=%d, want 2", len(runner.calls)) + } + if err := runner.calls[1].ctx.Err(); !errors.Is(err, context.Canceled) { + t.Fatalf("external delete runner context=%v", err) + } + if model.IsOpen() || model.title.Value() == "late" { + t.Fatal("late result revived deleted editor") + } + + runner.err = errors.New("upstream\x1b[31m\nfailed") + runner.run.Cards = nil + run(t, &model, model.OpenAdd(board.StatusTodo)) + model.draftPrompt.SetValue("fail") + model.Update(commandMsgForEditor(t, model.startDraft())) + if !model.statusIsError || strings.Contains(model.statusMessage, "\x1b") || strings.Contains(model.statusMessage, "\nfailed") { + t.Fatalf("unsafe error status = %q", model.statusMessage) + } + runner.err = nil + model.Update(commandMsgForEditor(t, model.startDraft())) + if !strings.Contains(model.statusMessage, "no usable card") { + t.Fatalf("empty proposal status = %q", model.statusMessage) + } +} + +func TestAIDraftUnavailableBlankStaleAndShutdownBranches(t *testing.T) { + model := New(newTestStore(t), "u") + run(t, &model, model.OpenAdd(board.StatusTodo)) + if command := model.startDraft(); command != nil { + t.Fatal("missing runner started draft") + } + runner := &fakeDraftRunner{run: ai.RunResult{Cards: []ai.Draft{{Title: "ok", Prio: 3}}}} + model.SetAIRunner(runner, nil) + if command := model.startDraft(); command != nil || !model.statusIsError || !strings.Contains(model.statusMessage, "required") { + t.Fatalf("blank request command=%v status=%q", command, model.statusMessage) + } + model.draftPrompt.SetValue("draft") + command := model.startDraft() + model.CancelAsync() + if model.drafting || model.draftCancel != nil { + t.Fatal("shutdown left draft active") + } + model.Update(commandMsgForEditor(t, command)) + + model.Update(draftCompletedMsg{session: model.session + 1, generation: model.draftGen, draft: ai.Draft{Title: "stale"}}) + model.Update(draftCompletedMsg{session: model.session, generation: model.draftGen + 1, draft: ai.Draft{Title: "stale"}}) + if model.title.Value() == "stale" { + t.Fatal("stale result applied") + } + model.SetAIRunner(nil, context.Background()) +} + +func commandMsgForEditor(t *testing.T, command tea.Cmd) tea.Msg { + t.Helper() + if command == nil { + t.Fatal("command is nil") + } + return command() +} diff --git a/internal/tui/cardeditor/view.go b/internal/tui/cardeditor/view.go index a9a497b..f15ee10 100644 --- a/internal/tui/cardeditor/view.go +++ b/internal/tui/cardeditor/view.go @@ -11,6 +11,7 @@ import ( "github.com/RandomCodeSpace/kb/internal/board" "github.com/RandomCodeSpace/kb/internal/store" + "github.com/RandomCodeSpace/kb/internal/tui/formview" ) const maxEditorWidth = 96 @@ -79,6 +80,8 @@ func (m *Model) frame(width, height int) (string, int, int) { } if m.guardClose { footer = "D discard | esc keep editing" + } else if m.drafting { + footer = "drafting card... | esc cancel" } else if m.saving { footer = "saving card..." } @@ -102,6 +105,15 @@ func (m *Model) bodyLines(width int) []string { } } lines := []string{title, ""} + if m.runner != nil { + lines = append(lines, "Draft with AI (fills the form; review before Save)") + lines = append(lines, m.areaBlock("ai-prompt", "Request", m.draftPrompt, width, 2)...) + action := "Draft" + if m.drafting { + action = "Cancel draft (Esc)" + } + lines = append(lines, m.actionLine("ai-draft", action), "") + } lines = append(lines, m.inputLine("title", "Title", m.title, width), m.inputLine("emoji", "Emoji", m.emoji, width), @@ -238,41 +250,11 @@ func similarText(hit store.SimilarHit) string { } func inputDisplay(input textinput.Model, focused bool, width int) string { - value := input.Value() - if value == "" { - value = input.Placeholder - } - runes := []rune(value) - position := min(max(input.Position(), 0), len(runes)) - safe := sanitize(value) - safePosition := len([]rune(sanitize(string(runes[:position])))) - if !focused { - return ansi.Truncate(safe, max(width, 0), "") - } - return cursorViewport(safe, safePosition, width) + return formview.Input(input, focused, width, sanitize, cursorViewport) } func areaDisplay(area textarea.Model, focused bool, width, rows int) []string { - value := area.Value() - if value == "" { - value = area.Placeholder - } - logical := strings.Split(value, "\n") - line := min(max(area.Line(), 0), len(logical)-1) - start := max(line-rows+1, 0) - end := min(start+rows, len(logical)) - out := make([]string, 0, rows) - for i := start; i < end; i++ { - content := sanitize(logical[i]) - if focused && i == line { - content = cursorViewport(content, min(area.Column(), len([]rune(content))), max(width-4, 1)) - } - out = append(out, " "+ansi.Truncate(content, max(width-4, 0), "")) - } - for len(out) < rows { - out = append(out, " ") - } - return out + return formview.Area(area, focused, width, rows, sanitize, cursorViewport) } func cursorViewport(value string, position, width int) string { diff --git a/internal/tui/cardeditor/view_test.go b/internal/tui/cardeditor/view_test.go index 06a41ce..9c4e103 100644 --- a/internal/tui/cardeditor/view_test.go +++ b/internal/tui/cardeditor/view_test.go @@ -1,6 +1,7 @@ package cardeditor import ( + "context" "errors" "strings" "testing" @@ -95,6 +96,30 @@ func TestViewCoversErrorsSuggestionsGuardsAndControlSafety(t *testing.T) { } } +func TestAIDraftControlsRenderProgressAndControlSafePrompt(t *testing.T) { + runner := &fakeDraftRunner{} + model := New(newTestStore(t), "u") + model.SetAIRunner(runner, nil) + model.SetAIRunner(runner, context.Background()) + model.OpenAdd(board.StatusTodo) + model.focus = "ai-prompt" + model.draftPrompt.SetValue("draft\x1b[31m\nthis") + view := ansi.Strip(model.View(78, 24)) + for _, want := range []string{"Draft with AI", "fills the form", "Request", "Draft"} { + if !strings.Contains(view, want) { + t.Errorf("AI editor view missing %q:\n%s", want, view) + } + } + if strings.Contains(view, "\x1b") { + t.Fatalf("AI prompt control reached view: %q", view) + } + model.drafting = true + model.focus = "ai-draft" + if got := ansi.Strip(model.View(78, 16)); !strings.Contains(got, "esc cancel") || !strings.Contains(got, "Cancel draft") { + t.Fatalf("draft progress missing:\n%s", got) + } +} + func TestOverlayAndTinyViewStayBounded(t *testing.T) { model := New(newTestStore(t), "u") background := strings.Repeat("b", 30) + "\n" + strings.Repeat("b", 30) diff --git a/internal/tui/formview/fields.go b/internal/tui/formview/fields.go new file mode 100644 index 0000000..3f061b5 --- /dev/null +++ b/internal/tui/formview/fields.go @@ -0,0 +1,62 @@ +// Package formview renders terminal form controls shared by TUI overlays. +package formview + +import ( + "strings" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "github.com/charmbracelet/x/ansi" +) + +// Input renders one text input within the requested terminal width. +func Input( + input textinput.Model, + focused bool, + width int, + clean func(string) string, + cursor func(string, int, int) string, +) string { + value := input.Value() + if value == "" { + value = input.Placeholder + } + runes := []rune(value) + position := min(max(input.Position(), 0), len(runes)) + safe := clean(value) + safePosition := len([]rune(clean(string(runes[:position])))) + if !focused { + return ansi.Truncate(safe, max(width, 0), "") + } + return cursor(safe, safePosition, width) +} + +// Area renders a fixed-height textarea viewport. +func Area( + area textarea.Model, + focused bool, + width, rows int, + clean func(string) string, + cursor func(string, int, int) string, +) []string { + value := area.Value() + if value == "" { + value = area.Placeholder + } + logical := strings.Split(value, "\n") + line := min(max(area.Line(), 0), len(logical)-1) + start := max(line-rows+1, 0) + end := min(start+rows, len(logical)) + out := make([]string, 0, rows) + for i := start; i < end; i++ { + content := clean(logical[i]) + if focused && i == line { + content = cursor(content, min(area.Column(), len([]rune(content))), max(width-4, 1)) + } + out = append(out, " "+ansi.Truncate(content, max(width-4, 0), "")) + } + for len(out) < rows { + out = append(out, " ") + } + return out +} diff --git a/internal/tui/formview/fields_test.go b/internal/tui/formview/fields_test.go new file mode 100644 index 0000000..3eb0bf1 --- /dev/null +++ b/internal/tui/formview/fields_test.go @@ -0,0 +1,46 @@ +package formview + +import ( + "fmt" + "strings" + "testing" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" +) + +func TestInputValuePlaceholderFocusAndWidth(t *testing.T) { + input := textinput.New() + input.Placeholder = "placeholder" + clean := strings.ToUpper + cursor := func(value string, position, width int) string { + return fmt.Sprintf("%s|%d|%d", value, position, width) + } + if got := Input(input, false, 5, clean, cursor); got != "PLACE" { + t.Fatalf("placeholder = %q", got) + } + if got := Input(input, false, 0, clean, cursor); got != "" { + t.Fatalf("zero-width input = %q", got) + } + input.SetValue("value") + if got := Input(input, true, 7, clean, cursor); got != "VALUE|5|7" { + t.Fatalf("focused value = %q", got) + } +} + +func TestAreaValuePlaceholderFocusAndPadding(t *testing.T) { + area := textarea.New() + area.Placeholder = "placeholder" + clean := strings.ToUpper + cursor := func(value string, _, _ int) string { + return "|" + value + } + if got := Area(area, false, 20, 2, clean, cursor); len(got) != 2 || !strings.Contains(got[0], "PLACEHOLDER") || got[1] != " " { + t.Fatalf("placeholder area = %#v", got) + } + area.SetValue("line one\nline two") + got := Area(area, true, 12, 2, clean, cursor) + if len(got) != 2 || !strings.Contains(got[1], "|LINE TW") { + t.Fatalf("focused area = %#v", got) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 229ee5e..649ccb0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,7 +8,9 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/tui/adrsplit" "github.com/RandomCodeSpace/kb/internal/tui/carddetail" "github.com/RandomCodeSpace/kb/internal/tui/cardeditor" ) @@ -59,6 +61,7 @@ type Model struct { filter boardFilterState detail carddetail.Model editor cardeditor.Model + adr adrsplit.Model selectAfterLoad string width int height int @@ -80,6 +83,15 @@ type Model struct { move cardMoveState } +func (m *Model) configureAI(runner *ai.Runner, ctx context.Context) { + if runner == nil { + return + } + m.editor.SetAIRunner(runner, ctx) + adrStore, _ := m.store.(adrsplit.Store) + m.adr = adrsplit.New(adrStore, runner, m.user, ctx) +} + // NewModel creates the root model for one local board owner. func NewModel(store boardReader, watcher dataVersionReader, user string) Model { return newModel(store, watcher, user, context.Background()) @@ -165,6 +177,26 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + if m.adr.IsOpen() && adrsplit.IsMessage(message) { + command := m.adr.Update(message) + if m.adr.ConsumeChanged() { + return m, batchCommands(command, m.requireFreshBoard()) + } + return m, command + } + if m.adr.IsOpen() { + switch msg := message.(type) { + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + break + } + return m, m.adr.Update(msg) + case boardCardClickedMsg, boardColumnClickedMsg, + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg, + boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg: + return m, nil + } + } var detailCmd tea.Cmd if m.detail.IsOpen() { switch msg := message.(type) { @@ -210,6 +242,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.settings != nil { m.settings.Close() } + m.editor.CancelAsync() + m.adr.Close() m.stopped = true m.reloadPending = false return m, tea.Quit @@ -240,7 +274,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.boardView.focusTask(m.filteredBoard(), m.move.lifted.taskID) } return m, nil - case "s", "c", "1", "2", "3", "4", "tab", "shift+tab", "n", "e", "/", "f", "x": + case "s", "c", "1", "2", "3", "4", "tab", "shift+tab", "n", "e", "a", "/", "f", "x": m.cancelCardMove("focus changed") default: return m, nil @@ -259,6 +293,10 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.settings = m.settingsNew() return m, m.settings.Init() } + case "a": + if m.adr.Enabled() && !m.move.saving { + return m, m.adr.Open() + } case "enter": if task, ok := m.selectedTask(); ok { m.detail.Resize(m.width, m.height) @@ -578,6 +616,10 @@ func (m Model) View() tea.View { content = m.settings.View(m.width, m.height) hits = nil } + if m.adr.IsOpen() { + content = m.adr.Overlay(content, m.width, m.height) + hits = nil + } if m.editor.IsOpen() { content = m.editor.Overlay(content, m.width, m.height) hits = nil @@ -585,7 +627,7 @@ func (m Model) View() tea.View { view := tea.NewView(content) view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion - if m.settings == nil && !m.editor.IsOpen() { + if m.settings == nil && !m.editor.IsOpen() && !m.adr.IsOpen() { pointerActive := m.move.lifted != nil && m.move.lifted.fromMouse view.OnMouse = boardMouseHandler(hits, pointerActive) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index c2a4462..304343a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/teatest/v2" + "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/board" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -789,6 +790,61 @@ func TestShutdownIgnoresLateResults(t *testing.T) { } } +func TestADRSplitRootRoutingMoveCancellationAndShutdown(t *testing.T) { + st := newSettingsTestStore(t) + task, err := st.AddTask("u", board.Task{Title: "card", Status: board.StatusTodo, Prio: 3}) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "u") + m.configureAI(ai.NewRunner(st, "", nil, nil), context.Background()) + m.board = board.Board{Title: "Work", Tasks: []board.Task{task}} + m.boardView.adoptBoard(m.board, m.board) + m.loading = false + if !m.adr.Enabled() || m.adr.IsOpen() { + t.Fatalf("ADR wiring enabled=%v open=%v", m.adr.Enabled(), m.adr.IsOpen()) + } + m.move.beginVisible(m.board, m.board, task, m.boardView.visibleStatuses(), false) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'a'}) + if !m.adr.IsOpen() || m.move.lifted != nil || m.board.Tasks[0].ID != task.ID { + t.Fatalf("ADR open did not restore lift: open=%v move=%#v board=%#v", m.adr.IsOpen(), m.move, m.board) + } + if view := m.View(); view.OnMouse != nil || !strings.Contains(ansi.Strip(view.Content), "SPLIT ADR INTO STORIES") { + t.Fatalf("ADR view routing mouse=%v content:\n%s", view.OnMouse != nil, ansi.Strip(view.Content)) + } + + m.adr.Close() + m.move.beginVisible(m.board, m.board, task, m.boardView.visibleStatuses(), false) + m.move.saving = true + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'a'}); command != nil || m.adr.IsOpen() || m.move.lifted == nil { + t.Fatalf("move save allowed ADR command=%v open=%v lifted=%v", command, m.adr.IsOpen(), m.move.lifted != nil) + } + m.move.saving = false + m.cancelCardMove("") + m.move.status, m.move.notice = "", false + + detailLoad := m.detail.Open(task) + if detailLoad == nil { + t.Fatal("detail did not open") + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'a'}) + if m.adr.IsOpen() || !m.detail.IsOpen() { + t.Fatalf("ADR opened behind detail: adr=%v detail=%v", m.adr.IsOpen(), m.detail.IsOpen()) + } + m.detail.Close() + m.settingsNew = func() *settingsModel { return newSettingsModel(st, "u", context.Background()) } + footer := ansi.Strip(m.View().Content) + if !strings.Contains(footer, "a split ADR") { + t.Fatalf("ADR shortcut undiscoverable:\n%s", footer) + } + + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'a'}) + quit := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if quit == nil || !m.stopped || m.adr.IsOpen() { + t.Fatalf("ctrl-c shutdown command=%v stopped=%v adr=%v", quit, m.stopped, m.adr.IsOpen()) + } +} + func TestEmptyBoardGolden(t *testing.T) { m := NewModel(stubBoardReader{board: board.Board{Title: "Board"}}, nil, "default") // Start from a loaded snapshot so the golden records the frame, not a diff --git a/internal/tui/move_model_test.go b/internal/tui/move_model_test.go index 9cbeb95..3ca4e20 100644 --- a/internal/tui/move_model_test.go +++ b/internal/tui/move_model_test.go @@ -675,7 +675,7 @@ func TestMoveBoardViewCoverageEdges(t *testing.T) { if body, hits := joinColumns(nil); body != "" || hits != nil { t.Fatalf("empty columns = %q,%v", body, hits) } - if got := settingsBoardFooter("ready", "off", false, 1); got != "q" { + if got := settingsBoardFooter("ready", "off", false, false, 1); got != "q" { t.Fatalf("tiny footer = %q", got) } hits := []boardHit{{x1: 5, y1: 5, status: board.StatusDoing}} diff --git a/internal/tui/run.go b/internal/tui/run.go index d37ac41..943aef3 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "path/filepath" tea "charm.land/bubbletea/v2" + "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -32,9 +34,10 @@ func runWithSettings( openWatcher watcherOpener, options ...tea.ProgramOption, ) (err error) { + runner := ai.NewRunner(st, filepath.Join(filepath.Dir(databasePath), "skills"), nil, nil) return runProgram(st, databasePath, user, openWatcher, func(ctx context.Context) *settingsModel { return newSettingsModel(st, user, ctx) - }, options...) + }, runner, options...) } func run( @@ -44,7 +47,7 @@ func run( openWatcher watcherOpener, options ...tea.ProgramOption, ) (err error) { - return runProgram(st, databasePath, user, openWatcher, nil, options...) + return runProgram(st, databasePath, user, openWatcher, nil, nil, options...) } func runProgram( @@ -53,6 +56,7 @@ func runProgram( user string, openWatcher watcherOpener, settingsNew func(context.Context) *settingsModel, + aiRunner *ai.Runner, options ...tea.ProgramOption, ) (err error) { ctx, cancel := context.WithCancel(context.Background()) @@ -66,6 +70,7 @@ func runProgram( err = errors.Join(err, watcher.Close()) }() model := newModel(st, watcher, user, ctx) + model.configureAI(aiRunner, ctx) preferencePath, preferencePathErr := tuiPreferencesPath(databasePath, user) if preferencePathErr == nil { model.restorePreferences(preferencePath)