feat(tui-go): idea board viewer

OverlayIdeas (opened with I, global/cross-session) lists the idea board in the
artifact-viewer shell: ↑↓ to move, x/d to remove (sends DiscardIdea, optimistic
drop), esc to close. Pull-based via ListIdeas -> idea.list.
This commit is contained in:
2026-06-14 14:36:13 +04:00
parent 04e931c860
commit 02e963d6cc
8 changed files with 250 additions and 1 deletions
+82
View File
@@ -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())
}
+71
View File
@@ -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())
}
}
+6
View File
@@ -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
+2
View File
@@ -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
}
+8 -1
View File
@@ -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
+35
View File
@@ -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() {