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:
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user