diff --git a/apps/tui-go/internal/app/ideacard.go b/apps/tui-go/internal/app/ideacard.go new file mode 100644 index 00000000..2ae6b450 --- /dev/null +++ b/apps/tui-go/internal/app/ideacard.go @@ -0,0 +1,82 @@ +package app + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router +// captured while the operator rubber-ducked, each removable. Modeled on the artifact +// viewer's list shell, but ideas are short notes so there is no separate content pane. + +// ideaListRows is how many idea rows fit in the board (the rest is title + hints). +func (m Model) ideaListRows() int { + rows := m.height*70/100 - 5 + if rows < 1 { + rows = 1 + } + if rows > len(m.ideas) { + rows = len(m.ideas) + } + if rows < 1 { + rows = 1 + } + return rows +} + +func (m Model) ideasModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("ideas")) + if len(m.ideas) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.ideasIndex+1)+"/"+itoa(len(m.ideas))+")", t.P.Faint)) + } + b.WriteString(mbg(t, " · captured while rubber-ducking", t.P.Faint) + "\n\n") + + if m.ideasLoading { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if len(m.ideas) == 0 { + b.WriteString(mbg(t, " no ideas yet — they're saved as you think out loud with the router", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + // Windowed list, keeping the selected row in view. + rows := m.ideaListRows() + off := 0 + if len(m.ideas) > rows { + off = m.ideasIndex - rows/2 + if off < 0 { + off = 0 + } + if off > len(m.ideas)-rows { + off = len(m.ideas) - rows + } + } + end := off + rows + if end > len(m.ideas) { + end = len(m.ideas) + } + textW := w - 8 + for i := off; i < end; i++ { + marker := mbg(t, " ", t.P.BgPanel) + fg := t.P.Fg + if i == m.ideasIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + fg = t.P.FgStrong + } + text := strings.ReplaceAll(m.ideas[i].Text, "\n", " ") + b.WriteString(marker + lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(text, textW)) + "\n") + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "move"}, {"x", "remove"}, {"esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} diff --git a/apps/tui-go/internal/app/ideacard_test.go b/apps/tui-go/internal/app/ideacard_test.go new file mode 100644 index 00000000..7a7d2dc1 --- /dev/null +++ b/apps/tui-go/internal/app/ideacard_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +func ideaModel() (Model, *ws.Client) { + client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back + m := NewModel(client) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.openIdeas() // sets OverlayIdeas + sends a ListIdeas frame + m.applyServer(protocol.ServerMessage{ + Type: protocol.TypeIdeaList, + Ideas: []protocol.IdeaDto{ + {IdeaID: "i1", Text: "cache the repo map", CapturedAtMs: 1000}, + {IdeaID: "i2", Text: "add --dry-run", CapturedAtMs: 2000}, + }, + }) + return m, client +} + +func TestIdeaBoardOpensAndRenders(t *testing.T) { + m, _ := ideaModel() + if m.overlay != OverlayIdeas { + t.Fatalf("want OverlayIdeas, got %v", m.overlay) + } + out := m.ideasModal() + for _, want := range []string{"ideas", "cache the repo map", "add --dry-run"} { + if !strings.Contains(out, want) { + t.Fatalf("board missing %q:\n%s", want, out) + } + } +} + +func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) { + m, client := ideaModel() + _ = client.Drain() // drop the ListIdeas frame from openIdeas + + updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + m = updated.(Model) + + if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" { + t.Fatalf("expected i1 removed, got %+v", m.ideas) + } + frames := client.Drain() + if len(frames) != 1 { + t.Fatalf("expected one DiscardIdea frame, got %d", len(frames)) + } + var raw map[string]any + if err := json.Unmarshal(frames[0], &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if raw["type"] != "com.correx.apps.server.protocol.ClientMessage.DiscardIdea" || raw["ideaId"] != "i1" { + t.Fatalf("expected DiscardIdea i1, got %+v", raw) + } +} + +func TestIdeaBoardEmptyState(t *testing.T) { + m, _ := ideaModel() + m.applyServer(protocol.ServerMessage{Type: protocol.TypeIdeaList, Ideas: nil}) + if !strings.Contains(m.ideasModal(), "no ideas yet") { + t.Fatalf("expected empty state, got:\n%s", m.ideasModal()) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index f2b13f6f..190274b0 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -52,6 +52,7 @@ const ( OverlayArtifacts OverlayConfig OverlayStats + OverlayIdeas ) // RouterEntry is one line in a session's conversation transcript. @@ -253,6 +254,11 @@ type Model struct { statsFor string // sessionId the current stats belong to statsLoading bool + // idea board (OverlayIdeas) — cross-session, populated by the idea.list reply + ideas []protocol.IdeaDto + ideasIndex int + ideasLoading bool + // command palette paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 10a2e00d..10cd6b4d 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -59,6 +59,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.configModal()) case OverlayStats: return m.center(m.statsModal()) + case OverlayIdeas: + return m.center(m.ideasModal()) } return base } diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 94791a41..dd18b255 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -316,6 +316,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { m.stats = msg.Stats m.statsFor = msg.SessionID m.statsLoading = false + case protocol.TypeIdeaList: + m.ideas = msg.Ideas + m.ideasLoading = false + if m.ideasIndex >= len(m.ideas) { + m.ideasIndex = 0 + } case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -360,7 +366,8 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeProtocolError, protocol.TypeProviderStatus, protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, - protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats: + protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, + protocol.TypeIdeaList: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 4375830b..8d8c5edb 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -176,6 +176,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlay = OverlayToolPalette case "v": m.openArtifacts() + case "I": + m.openIdeas() case "S": m.openStats() case "g": @@ -442,6 +444,28 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if runeIs(k, "S") { m.overlay = OverlayNone } + case OverlayIdeas: + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.ideasIndex > 0 { + m.ideasIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.ideasIndex < len(m.ideas)-1 { + m.ideasIndex++ + } + case runeIs(k, "x") || runeIs(k, "d"): + if m.ideasIndex >= 0 && m.ideasIndex < len(m.ideas) { + m.client.Send(protocol.DiscardIdea(m.ideas[m.ideasIndex].IdeaID)) + // Optimistic removal; the server's fresh idea.list reply reconciles. + m.ideas = append(m.ideas[:m.ideasIndex], m.ideas[m.ideasIndex+1:]...) + if m.ideasIndex >= len(m.ideas) && m.ideasIndex > 0 { + m.ideasIndex-- + } + } + case runeIs(k, "I"): + m.overlay = OverlayNone + } } return m, nil } @@ -463,6 +487,17 @@ func (m *Model) openArtifacts() { m.client.Send(protocol.ListArtifacts(m.selectedID)) } +// openIdeas opens the cross-session idea board and requests it from the server. The board +// is global (not session-scoped), so it opens from anywhere — including the idle list. +func (m *Model) openIdeas() { + m.overlay = OverlayIdeas + m.ideasIndex = 0 + if m.ideas == nil { + m.ideasLoading = true + } + m.client.Send(protocol.ListIdeas()) +} + // openStats opens the session-stats pane for the selected session and requests its // metrics from the server. No-op when no session is selected. func (m *Model) openStats() { diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index 96ae408d..9e77cb70 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -149,6 +149,21 @@ func TestDecodeGoldenServerFrames(t *testing.T) { } }, }, + { + name: "idea.list carries the cross-session board", + json: `{"type":"idea.list","ideas":[{"ideaId":"i1","text":"cache the repo map","capturedAtMs":1000},` + + `{"ideaId":"i2","text":"add --dry-run","capturedAtMs":2000}]}`, + wantType: TypeIdeaList, + eventBear: false, + check: func(t *testing.T, m ServerMessage) { + if len(m.Ideas) != 2 { + t.Fatalf("idea.list ideas: %+v", m.Ideas) + } + if m.Ideas[0].IdeaID != "i1" || m.Ideas[0].Text != "cache the repo map" || m.Ideas[0].CapturedAtMs != 1000 { + t.Fatalf("idea.list idea[0]: %+v", m.Ideas[0]) + } + }, + }, { name: "workflow.proposed carries candidate workflows", json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` + @@ -233,6 +248,16 @@ func TestEncodeGoldenClientFrames(t *testing.T) { } }, }, + { + name: "DiscardIdea carries the ideaId", + frame: DiscardIdea("i1"), + wantType: clientPrefix + "DiscardIdea", + check: func(t *testing.T, raw map[string]any) { + if raw["ideaId"] != "i1" { + t.Fatalf("DiscardIdea ideaId: %+v", raw) + } + }, + }, { name: "ClarificationResponse carries ids + answers", frame: ClarificationResponse("s1", "analyst", "req-1", diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index e3cae822..b7e3e41d 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -54,6 +54,7 @@ const ( TypeArtifactList = "artifact.list" TypeConfigSnapshot = "config.snapshot" TypeSessionStats = "session.stats" + TypeIdeaList = "idea.list" ) // ServerMessage is a flat decode of every server->client variant. Field names @@ -146,6 +147,16 @@ type ServerMessage struct { Prompt string `json:"prompt"` OriginalRequest string `json:"originalRequest"` Candidates []ProposedWorkflowDto `json:"candidates"` + + // idea.list — the cross-session idea board + Ideas []IdeaDto `json:"ideas"` +} + +// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto. +type IdeaDto struct { + IdeaID string `json:"ideaId"` + Text string `json:"text"` + CapturedAtMs int64 `json:"capturedAtMs"` } // ProposedWorkflowDto is one candidate workflow the router suggests, with a short @@ -385,6 +396,16 @@ func GetConfig() []byte { return encode("GetConfig", map[string]any{}) } +// ListIdeas requests the cross-session idea board (replied to with an idea.list). +func ListIdeas() []byte { + return encode("ListIdeas", map[string]any{}) +} + +// DiscardIdea removes an idea from the board (server tombstones it; replies with a fresh idea.list). +func DiscardIdea(ideaID string) []byte { + return encode("DiscardIdea", map[string]any{"ideaId": ideaID}) +} + // UpdateConfig asks the server to apply and persist a config patch (key -> string value). func UpdateConfig(patch map[string]string) []byte { return encode("UpdateConfig", map[string]any{"patch": patch})