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
|
OverlayArtifacts
|
||||||
OverlayConfig
|
OverlayConfig
|
||||||
OverlayStats
|
OverlayStats
|
||||||
|
OverlayIdeas
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// 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
|
statsFor string // sessionId the current stats belong to
|
||||||
statsLoading bool
|
statsLoading bool
|
||||||
|
|
||||||
|
// idea board (OverlayIdeas) — cross-session, populated by the idea.list reply
|
||||||
|
ideas []protocol.IdeaDto
|
||||||
|
ideasIndex int
|
||||||
|
ideasLoading bool
|
||||||
|
|
||||||
// command palette
|
// command palette
|
||||||
paletteFilter string
|
paletteFilter string
|
||||||
paletteIndex int
|
paletteIndex int
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.configModal())
|
return m.center(m.configModal())
|
||||||
case OverlayStats:
|
case OverlayStats:
|
||||||
return m.center(m.statsModal())
|
return m.center(m.statsModal())
|
||||||
|
case OverlayIdeas:
|
||||||
|
return m.center(m.ideasModal())
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
m.stats = msg.Stats
|
m.stats = msg.Stats
|
||||||
m.statsFor = msg.SessionID
|
m.statsFor = msg.SessionID
|
||||||
m.statsLoading = false
|
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:
|
case protocol.TypeConfigSnapshot:
|
||||||
m.configFields = msg.ConfigFields
|
m.configFields = msg.ConfigFields
|
||||||
m.configRestart = msg.ConfigRestartRequired
|
m.configRestart = msg.ConfigRestartRequired
|
||||||
@@ -360,7 +366,8 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
|||||||
protocol.TypeProtocolError, protocol.TypeProviderStatus,
|
protocol.TypeProtocolError, protocol.TypeProviderStatus,
|
||||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats:
|
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||||
|
protocol.TypeIdeaList:
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
return msg.SessionID
|
return msg.SessionID
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.overlay = OverlayToolPalette
|
m.overlay = OverlayToolPalette
|
||||||
case "v":
|
case "v":
|
||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
|
case "I":
|
||||||
|
m.openIdeas()
|
||||||
case "S":
|
case "S":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
case "g":
|
case "g":
|
||||||
@@ -442,6 +444,28 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if runeIs(k, "S") {
|
if runeIs(k, "S") {
|
||||||
m.overlay = OverlayNone
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -463,6 +487,17 @@ func (m *Model) openArtifacts() {
|
|||||||
m.client.Send(protocol.ListArtifacts(m.selectedID))
|
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
|
// openStats opens the session-stats pane for the selected session and requests its
|
||||||
// metrics from the server. No-op when no session is selected.
|
// metrics from the server. No-op when no session is selected.
|
||||||
func (m *Model) openStats() {
|
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",
|
name: "workflow.proposed carries candidate workflows",
|
||||||
json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` +
|
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",
|
name: "ClarificationResponse carries ids + answers",
|
||||||
frame: ClarificationResponse("s1", "analyst", "req-1",
|
frame: ClarificationResponse("s1", "analyst", "req-1",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const (
|
|||||||
TypeArtifactList = "artifact.list"
|
TypeArtifactList = "artifact.list"
|
||||||
TypeConfigSnapshot = "config.snapshot"
|
TypeConfigSnapshot = "config.snapshot"
|
||||||
TypeSessionStats = "session.stats"
|
TypeSessionStats = "session.stats"
|
||||||
|
TypeIdeaList = "idea.list"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
// ServerMessage is a flat decode of every server->client variant. Field names
|
||||||
@@ -146,6 +147,16 @@ type ServerMessage struct {
|
|||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
OriginalRequest string `json:"originalRequest"`
|
OriginalRequest string `json:"originalRequest"`
|
||||||
Candidates []ProposedWorkflowDto `json:"candidates"`
|
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
|
// ProposedWorkflowDto is one candidate workflow the router suggests, with a short
|
||||||
@@ -385,6 +396,16 @@ func GetConfig() []byte {
|
|||||||
return encode("GetConfig", map[string]any{})
|
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).
|
// UpdateConfig asks the server to apply and persist a config patch (key -> string value).
|
||||||
func UpdateConfig(patch map[string]string) []byte {
|
func UpdateConfig(patch map[string]string) []byte {
|
||||||
return encode("UpdateConfig", map[string]any{"patch": patch})
|
return encode("UpdateConfig", map[string]any{"patch": patch})
|
||||||
|
|||||||
Reference in New Issue
Block a user