d247b19608
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).
Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).
Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
4.3 KiB
Go
142 lines
4.3 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/correx/tui-go/internal/protocol"
|
|
"github.com/correx/tui-go/internal/ws"
|
|
)
|
|
|
|
func proposeModel() (Model, *ws.Client) {
|
|
client := ws.New("", 0) // unconnected; Send just buffers, Drain reads it back
|
|
m := NewModel(client)
|
|
m.width, m.height = 120, 40
|
|
m.theme = NewTheme(SoftBlue)
|
|
m.selectedID = "s1"
|
|
m.sessionEntered = true
|
|
m.applyServer(protocol.ServerMessage{
|
|
Type: protocol.TypeWorkflowProposed,
|
|
SessionID: "s1",
|
|
ProposalID: "prop-1",
|
|
Prompt: "Run one of these?",
|
|
OriginalRequest: "find papers on event sourcing",
|
|
Candidates: []protocol.ProposedWorkflowDto{
|
|
{WorkflowID: "research", Reason: "gather + report"},
|
|
{WorkflowID: "role_pipeline", Reason: "full build"},
|
|
},
|
|
})
|
|
return m, client
|
|
}
|
|
|
|
func TestProposeEntersStateAndRenders(t *testing.T) {
|
|
m, _ := proposeModel()
|
|
if m.displayState() != StateWorkflowPropose {
|
|
t.Fatalf("want StateWorkflowPropose, got %v", m.displayState())
|
|
}
|
|
out := m.proposeModal()
|
|
for _, want := range []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline", "something else"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("modal missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
|
m, client := proposeModel()
|
|
// move cursor to the second candidate (role_pipeline) and launch it
|
|
m = applyProposeKey(m, keyMsg{Type: keyDown})
|
|
m = applyProposeKey(m, keyMsg{Type: keyEnter})
|
|
|
|
if s := m.session("s1"); s == nil || s.Propose != nil {
|
|
t.Fatalf("expected proposal cleared after launch")
|
|
}
|
|
if !m.pendingWorkflowFocus {
|
|
t.Fatalf("expected pendingWorkflowFocus set so the launched session is focused")
|
|
}
|
|
wf, input := decodeStart(t, client)
|
|
if wf != "role_pipeline" {
|
|
t.Fatalf("launched wrong workflow: %q", wf)
|
|
}
|
|
if input != "find papers on event sourcing" {
|
|
t.Fatalf("workflow not seeded with original request: %q", input)
|
|
}
|
|
}
|
|
|
|
func TestProposeFirstCandidateIsDefault(t *testing.T) {
|
|
m, client := proposeModel()
|
|
m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate
|
|
wf, _ := decodeStart(t, client)
|
|
if wf != "research" {
|
|
t.Fatalf("expected first candidate launched by default, got %q", wf)
|
|
}
|
|
}
|
|
|
|
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
|
|
m, client := proposeModel()
|
|
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
|
|
if !m.proposeTyping {
|
|
t.Fatalf("expected typing mode after 'e'")
|
|
}
|
|
for _, r := range "do it manually" {
|
|
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
|
|
}
|
|
m = applyProposeKey(m, keyMsg{Type: keyEnter})
|
|
|
|
if s := m.session("s1"); s == nil || s.Propose != nil {
|
|
t.Fatalf("expected proposal cleared after custom answer")
|
|
}
|
|
if m.pendingWorkflowFocus {
|
|
t.Fatalf("a custom answer must not launch a workflow")
|
|
}
|
|
frames := client.Drain()
|
|
if len(frames) != 1 {
|
|
t.Fatalf("expected exactly one 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.ChatInput" {
|
|
t.Fatalf("expected ChatInput, got %v", raw["type"])
|
|
}
|
|
if raw["text"] != "do it manually" {
|
|
t.Fatalf("custom text not sent: %v", raw["text"])
|
|
}
|
|
}
|
|
|
|
func TestProposeEscDismisses(t *testing.T) {
|
|
m, _ := proposeModel()
|
|
m = applyProposeKey(m, keyMsg{Type: keyEsc})
|
|
if m.displayState() != StateInSession {
|
|
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
|
|
}
|
|
if s := m.session("s1"); s == nil || s.Propose == nil {
|
|
t.Fatalf("esc should peek away, not discard the proposal")
|
|
}
|
|
}
|
|
|
|
func applyProposeKey(m Model, k keyMsg) Model {
|
|
updated, _ := m.handleProposeKey(k)
|
|
return updated.(Model)
|
|
}
|
|
|
|
func decodeStart(t *testing.T, client *ws.Client) (workflowID, input string) {
|
|
t.Helper()
|
|
frames := client.Drain()
|
|
if len(frames) != 1 {
|
|
t.Fatalf("expected exactly one 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.StartSession" {
|
|
t.Fatalf("expected StartSession, got %v", raw["type"])
|
|
}
|
|
wf, _ := raw["workflowId"].(string)
|
|
in, _ := raw["input"].(string)
|
|
return wf, in
|
|
}
|