tui: migrate Bubble Tea v1 → v2 (charm.land), enable Shift+Enter

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>
This commit is contained in:
2026-06-22 15:59:55 +00:00
parent 6c792b83e2
commit d247b19608
27 changed files with 432 additions and 300 deletions
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// inSessionModel builds an in-session model with deliberately long fields (model name,
@@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) {
func TestViewNeverOverflowsWidth(t *testing.T) {
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
m := inSessionModel(w, 30)
assertNoOverflow(t, m.View(), w)
assertNoOverflow(t, m.render(), w)
}
}
@@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) {
m.overlay = OverlayStats
m.statsFor = m.selectedID
m.stats = sampleStats()
assertNoOverflow(t, m.View(), w)
assertNoOverflow(t, m.render(), w)
}
}
+6 -7
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -40,12 +39,12 @@ func apprReq(reqID, tier string) protocol.ServerMessage {
}
// applyKey drives a key through the production handleKey entry and returns the model.
func applyKey(m Model, k tea.KeyMsg) Model {
func applyKey(m Model, k keyMsg) Model {
updated, _ := m.handleKey(k)
return updated.(Model)
}
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} }
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
// its request id + decision. Fails if there isn't exactly one decision frame.
@@ -146,7 +145,7 @@ func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) {
// no decision may be emitted.
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
m = applyKey(m, runeKey('y')) // arm req-1
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyKey(m, keyMsg{Type: keyDown})
if m.approvalArmed {
t.Fatalf("nav key should disarm")
}
@@ -168,7 +167,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
t.Fatalf("band should show queue count 1/3:\n%s", out)
}
// ↓ advances the selection; Pending follows the cursor.
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyKey(m, keyMsg{Type: keyDown})
s = m.session("s1")
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
@@ -178,7 +177,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
}
// ↑ wraps back.
m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp})
m = applyKey(m, keyMsg{Type: keyUp})
if m.session("s1").PendingIdx != 0 {
t.Fatalf("↑ should return to index 0")
}
@@ -238,7 +237,7 @@ func TestApprovalRejectAndSteerRoute(t *testing.T) {
for _, r := range "use sudo" {
m2 = applyKey(m2, runeKey(r))
}
m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter})
m2 = applyKey(m2, keyMsg{Type: keyEnter})
frames := client2.Drain()
var note string
for _, f := range frames {
+16 -16
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
// --- key handling ---
func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Clar == nil {
return m, nil
@@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleClarTyping(k), nil
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.clarDismissed = true
case tea.KeyUp:
case keyUp:
m.clarFocusMove(-1, qs)
case tea.KeyDown:
case keyDown:
m.clarFocusMove(1, qs)
case tea.KeyLeft:
case keyLeft:
m.clarCursorMove(-1, qs)
case tea.KeyRight:
case keyRight:
m.clarCursorMove(1, qs)
case tea.KeySpace:
case keySpace:
m.clarSelect(qs)
case tea.KeyEnter:
case keyEnter:
return m.clarSubmit()
case tea.KeyRunes:
case keyRunes:
switch string(k.Runes) {
case "k":
m.clarFocusMove(-1, qs)
@@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model {
func (m Model) handleClarTyping(k keyMsg) tea.Model {
if m.clarFocus >= len(m.clarText) {
return m
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.clarTyping = false
case tea.KeyEnter:
case keyEnter:
m.clarTyping = false
// committing custom text supersedes any chosen option for this question
if m.clarFocus < len(m.clarChosen) {
m.clarChosen[m.clarFocus] = map[int]bool{}
}
case tea.KeyBackspace:
case keyBackspace:
if t := m.clarText[m.clarFocus]; len(t) > 0 {
m.clarText[m.clarFocus] = t[:len(t)-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.clarText[m.clarFocus] += string(k.Runes)
}
return m
+9 -10
View File
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) {
func TestClarificationSelectAndSubmit(t *testing.T) {
m := clarModel()
// move the option cursor to "Vue" (index 1) and select it
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
m = applyClarKey(m, keyMsg{Type: keyRight})
m = applyClarKey(m, keyMsg{Type: keySpace})
if !m.clarChosenAt(0, 1) {
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
}
// submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar != nil {
t.Fatalf("expected clarification cleared after submit")
}
@@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
},
})
// answer only the first question, then attempt submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keySpace})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar == nil {
t.Fatalf("expected clarification to remain with partial answers")
}
@@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
func TestClarificationCustomTextAnswer(t *testing.T) {
m := clarModel()
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
if !m.clarTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "Solid" {
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if m.clarTyping {
t.Fatalf("expected typing committed")
}
@@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) {
}
}
func applyClarKey(m Model, k tea.KeyMsg) Model {
func applyClarKey(m Model, k keyMsg) Model {
updated, _ := m.handleClarificationKey(k)
return updated.(Model)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
+11 -11
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -23,40 +23,40 @@ func (m *Model) openConfig() {
// handleConfigKey owns every key while the config overlay is open. In edit mode it captures the
// value being typed; otherwise it navigates fields, stages edits, and saves.
func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.configEditing {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.configEditing = false
m.configEditBuf = ""
case tea.KeyEnter:
case keyEnter:
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
}
m.configEditing = false
m.configEditBuf = ""
case tea.KeyBackspace:
case keyBackspace:
if n := len(m.configEditBuf); n > 0 {
m.configEditBuf = m.configEditBuf[:n-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.configEditBuf += string(k.Runes)
}
return m, nil
}
switch {
case k.Type == tea.KeyEsc || runeIs(k, "g"):
case k.Type == keyEsc || runeIs(k, "g"):
m.overlay = OverlayNone
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.configIndex > 0 {
m.configIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.configIndex < len(m.configFields)-1 {
m.configIndex++
}
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace:
case k.Type == keyEnter || k.Type == keySpace:
m = m.actOnConfigField()
case runeIs(k, "s"):
if len(m.configStaged) > 0 {
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
)
@@ -23,7 +22,7 @@ func configModel() Model {
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
m := configModel()
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configStaged["project.enabled"] != "true" {
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
@@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
func TestConfigEnumCycles(t *testing.T) {
m := configModel()
m.configIndex = 1 // tui.theme
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configStaged["tui.theme"] != "light" {
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
@@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
m := configModel()
m.configIndex = 2 // router.generation.max_tokens
// Enter edit mode.
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if !m.configEditing {
t.Fatalf("expected edit mode after enter on INT field")
@@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
// Clear the seeded buffer and type a new value.
m.configEditBuf = ""
for _, r := range "1024" {
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}})
m = updated.(Model)
}
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configEditing {
t.Fatalf("expected edit mode to end after commit")
+1 -1
View File
@@ -256,7 +256,7 @@ func PreviewFrame(kind string, w, h int) string {
m.grantIndex = 1
m.overlay = OverlayGrants
}
return m.View()
return m.render()
}
func sampleStats() *protocol.StatsDto {
+3 -2
View File
@@ -1,10 +1,11 @@
package app
import (
"image/color"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
type diffKind int
@@ -196,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
if kind == diffBlank {
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
}
var bg, textFg, signFg lipgloss.Color
var bg, textFg, signFg color.Color
sign := " "
switch kind {
case diffAdd:
+1 -1
View File
@@ -3,7 +3,7 @@ package app
import (
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -43,7 +42,7 @@ 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")})
updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")})
m = updated.(Model)
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
+123
View File
@@ -0,0 +1,123 @@
package app
import tea "charm.land/bubbletea/v2"
// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form
// and dropped the v1 KeyType enum the handlers were written against. Rather than
// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same
// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic
// downstream stays untouched. The only place that ever touches the raw v2 key
// is toKeyMsg below, called once at the Update boundary.
type keyType int
const (
keyRunes keyType = iota
keyEnter
keyUp
keyDown
keyLeft
keyRight
keySpace
keyEsc
keyBackspace
keyTab
keyPgUp
keyPgDown
keyCtrlA
keyCtrlC
keyCtrlD
keyCtrlJ
keyCtrlR
keyCtrlU
keyCtrlX
keyCtrlUp
keyCtrlDown
keyOther
)
// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed
// Runes and modifier flags. Shift is newly meaningful under v2's kitty key
// disambiguation — it powers Shift+Enter newlines (see handleInsertKey).
type keyMsg struct {
Type keyType
Runes []rune
Alt bool
Shift bool
str string
}
func (k keyMsg) String() string { return k.str }
// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form.
func toKeyMsg(p tea.KeyPressMsg) keyMsg {
k := p.Key()
out := keyMsg{
Alt: k.Mod.Contains(tea.ModAlt),
Shift: k.Mod.Contains(tea.ModShift),
str: k.Keystroke(),
}
ctrl := k.Mod.Contains(tea.ModCtrl)
switch k.Code {
case tea.KeyEnter:
out.Type = keyEnter
case tea.KeyEscape:
out.Type = keyEsc
case tea.KeyTab:
out.Type = keyTab
case tea.KeyBackspace:
out.Type = keyBackspace
case tea.KeySpace:
out.Type = keySpace
out.Runes = []rune{' '}
case tea.KeyUp:
if ctrl {
out.Type = keyCtrlUp
} else {
out.Type = keyUp
}
case tea.KeyDown:
if ctrl {
out.Type = keyCtrlDown
} else {
out.Type = keyDown
}
case tea.KeyLeft:
out.Type = keyLeft
case tea.KeyRight:
out.Type = keyRight
case tea.KeyPgUp:
out.Type = keyPgUp
case tea.KeyPgDown:
out.Type = keyPgDown
default:
if ctrl {
switch k.Code {
case 'a':
out.Type = keyCtrlA
case 'c':
out.Type = keyCtrlC
case 'd':
out.Type = keyCtrlD
case 'j':
out.Type = keyCtrlJ
case 'r':
out.Type = keyCtrlR
case 'u':
out.Type = keyCtrlU
case 'x':
out.Type = keyCtrlX
default:
out.Type = keyOther
}
return out
}
if k.Text != "" {
out.Type = keyRunes
out.Runes = []rune(k.Text)
} else {
out.Type = keyOther
}
}
return out
}
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
+6 -5
View File
@@ -2,10 +2,11 @@ package app
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -21,8 +22,8 @@ import (
func (m Model) center(modal string) string {
if m.lastBase == "" {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
}
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
return modal // already a full-screen composite; don't re-dim it
@@ -143,7 +144,7 @@ func (m Model) titleLine(text string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
}
func mbg(t Theme, s string, fg lipgloss.Color) string {
func mbg(t Theme, s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
}
@@ -1217,7 +1218,7 @@ func shortID(id string) string {
return id[:13] + "…"
}
func tierColor(t Theme, tier int) lipgloss.Color {
func tierColor(t Theme, tier int) color.Color {
switch {
case tier >= 3:
return t.P.Bad
+13 -13
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -24,7 +24,7 @@ func (m *Model) proposeInitState() {
// --- key handling ---
func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Propose == nil {
return m, nil
@@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
n := len(s.Propose.Candidates) // index n == the custom slot
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.proposeDismissed = true
case tea.KeyUp:
case keyUp:
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
case tea.KeyDown:
case keyDown:
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
case tea.KeyEnter, tea.KeySpace:
case keyEnter, keySpace:
return m.proposeSubmit()
case tea.KeyRunes:
case keyRunes:
switch string(k.Runes) {
case "k":
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
@@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.proposeTyping = false
case tea.KeyEnter:
case keyEnter:
return m.proposeSubmit() // commit the custom answer and send it as chat
case tea.KeyBackspace:
case keyBackspace:
if len(m.proposeText) > 0 {
m.proposeText = m.proposeText[:len(m.proposeText)-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.proposeText += string(k.Runes)
}
return m, nil
+8 -9
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) {
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
m, client := proposeModel()
// move cursor to the second candidate (role_pipeline) and launch it
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
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")
@@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
func TestProposeFirstCandidateIsDefault(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate
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)
@@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) {
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
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, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyProposeKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after custom answer")
@@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) {
func TestProposeEscDismisses(t *testing.T) {
m, _ := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
m = applyProposeKey(m, keyMsg{Type: keyEsc})
if m.displayState() != StateInSession {
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
}
@@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) {
}
}
func applyProposeKey(m Model, k tea.KeyMsg) Model {
func applyProposeKey(m Model, k keyMsg) Model {
updated, _ := m.handleProposeKey(k)
return updated.(Model)
}
@@ -80,7 +80,7 @@ func renderSurface(m Model, s matrixSurface) string {
var raw string
switch s {
case surfaceView:
raw = m.View()
raw = m.render()
case surfaceEvents:
// Render the event-stream rows at full panel width so a row's detail isn't
// clipped at the slim right-panel edge (the production builder, just wide).
+7 -7
View File
@@ -7,8 +7,8 @@ import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// SessionSummary is one recent session from GET /sessions. Mirrors the server's
@@ -107,19 +107,19 @@ func (m *Model) openSessions() tea.Cmd {
// handleSessionsKey owns every key while the session browser is open: navigate
// the roster, esc closes, enter resumes the selected session.
func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) {
switch {
case k.Type == tea.KeyEsc || runeIs(k, "R"):
case k.Type == keyEsc || runeIs(k, "R"):
m.overlay = OverlayNone
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.sessionListIndex > 0 {
m.sessionListIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.sessionListIndex < len(m.sessionList)-1 {
m.sessionListIndex++
}
case k.Type == tea.KeyEnter:
case k.Type == keyEnter:
if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
return m.openSelectedSession()
}
@@ -3,8 +3,6 @@ package app
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func sessionsModel() Model {
@@ -25,7 +23,7 @@ func sampleSummaries() []SessionSummary {
func TestSessionsOverlayToggle(t *testing.T) {
m := sessionsModel()
// `R` opens the browser from the idle list.
updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")})
updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("R")})
m = updated.(Model)
if m.overlay != OverlaySessions {
t.Fatalf("expected OverlaySessions after R, got %d", m.overlay)
@@ -34,7 +32,7 @@ func TestSessionsOverlayToggle(t *testing.T) {
t.Fatalf("expected loading state right after open")
}
// `esc` closes it (routed through the overlay key handler).
updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc})
updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc})
m = updated.(Model)
if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
@@ -66,25 +64,25 @@ func TestSessionsNavigationBoundsChecked(t *testing.T) {
m.overlay = OverlaySessions
m.sessionList = sampleSummaries()
// Up at the top is a no-op (clamped at 0).
updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp})
updated, _ := m.handleSessionsKey(keyMsg{Type: keyUp})
m = updated.(Model)
if m.sessionListIndex != 0 {
t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex)
}
// Down moves to row 1.
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown})
updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
m = updated.(Model)
if m.sessionListIndex != 1 {
t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex)
}
// Down again is clamped at the last row.
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown})
updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
m = updated.(Model)
if m.sessionListIndex != 1 {
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex)
}
// `j` is the vim-style alias for down (still clamped here).
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
updated, _ = m.handleSessionsKey(keyMsg{Type: keyRunes, Runes: []rune("j")})
m = updated.(Model)
if m.sessionListIndex != 1 {
t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex)
@@ -96,7 +94,7 @@ func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) {
m.overlay = OverlaySessions
m.sessionList = sampleSummaries()
m.sessionListIndex = 1 // the FAILED healthcheck row
updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, cmd := m.handleSessionsKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after resume, got %d", m.overlay)
+2 -4
View File
@@ -2,8 +2,6 @@ package app
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
)
// `/` as the first char of a chat line opens the command palette inline (opencode-style),
@@ -14,7 +12,7 @@ func TestSlashOpensPaletteOnEmptyLine(t *testing.T) {
m.inputMode = ModeRouter
m.inputBuffer = ""
next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model)
if nm.overlay != OverlayPalette {
@@ -33,7 +31,7 @@ func TestSlashLiteralMidLine(t *testing.T) {
m.inputBuffer = "path"
m.inputCursor = len("path")
next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model)
if nm.overlay != OverlayNone {
+30 -26
View File
@@ -1,36 +1,40 @@
package app
import "github.com/charmbracelet/lipgloss"
import (
"image/color"
"charm.land/lipgloss/v2"
)
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
// rounded borders, opaque dark panels, generous spacing.
type Palette struct {
Bg lipgloss.Color // panel/background fill
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
BgPanel lipgloss.Color // inside-panel fill
Sel lipgloss.Color // selection row background
Border lipgloss.Color // idle panel border
BorderHi lipgloss.Color // active panel border
Fg lipgloss.Color // primary text
FgStrong lipgloss.Color // headings / emphasis
Dim lipgloss.Color // labels, secondary text
Faint lipgloss.Color // tertiary, hints
Accent lipgloss.Color // blue accent
Accent2 lipgloss.Color // secondary accent
Bg color.Color // panel/background fill
BgDeep color.Color // outermost backdrop (slightly darker)
BgPanel color.Color // inside-panel fill
Sel color.Color // selection row background
Border color.Color // idle panel border
BorderHi color.Color // active panel border
Fg color.Color // primary text
FgStrong color.Color // headings / emphasis
Dim color.Color // labels, secondary text
Faint color.Color // tertiary, hints
Accent color.Color // blue accent
Accent2 color.Color // secondary accent
OK lipgloss.Color
Warn lipgloss.Color
Bad lipgloss.Color
OK color.Color
Warn color.Color
Bad color.Color
DiffAdd lipgloss.Color // added-line cell background
DiffDel lipgloss.Color // removed-line cell background
DiffAdd color.Color // added-line cell background
DiffDel color.Color // removed-line cell background
CatLifecycle lipgloss.Color
CatContext lipgloss.Color
CatInference lipgloss.Color
CatTool lipgloss.Color
CatDomain lipgloss.Color
CatApproval lipgloss.Color
CatLifecycle color.Color
CatContext color.Color
CatInference color.Color
CatTool color.Color
CatDomain color.Color
CatApproval color.Color
}
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the
@@ -91,7 +95,7 @@ type Theme struct {
// NewTheme builds the style set for a palette.
func NewTheme(p Palette) Theme {
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg)
roundBase := func(border lipgloss.Color) lipgloss.Style {
roundBase := func(border color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(border).
@@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme {
}
// categoryColor maps an event category label to its accent color.
func (t Theme) categoryColor(cat string) lipgloss.Color {
func (t Theme) categoryColor(cat string) color.Color {
switch cat {
case "Lifecycle":
return t.P.CatLifecycle
+104 -103
View File
@@ -6,8 +6,8 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
osc52 "github.com/aymanbagabas/go-osc52/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -113,8 +113,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyMsg:
next, cmd := m.handleKey(msg)
case tea.KeyPressMsg:
next, cmd := m.handleKey(toKeyMsg(msg))
// A keypress may have entered a typing/active state (insert mode, steering, …);
// kick the tick so the caret/spinner animates again.
if nm, ok := next.(Model); ok {
@@ -175,12 +175,12 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) {
// --- key handling ---
func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) {
debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s",
k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay,
m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal.
if k.Type == tea.KeyCtrlC {
if k.Type == keyCtrlC {
m.quitting = true
return m, tea.Quit
}
@@ -198,17 +198,17 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer.
if m.displayState() == StateInSession {
switch k.Type {
case tea.KeyCtrlUp:
case keyCtrlUp:
m.transcriptNavUser(-1)
return m, nil
case tea.KeyCtrlDown:
case keyCtrlDown:
m.transcriptNavUser(1)
return m, nil
}
}
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
// edit mode, so it works whether or not the input is focused.
if m.displayState() == StateIdle && k.Type == tea.KeyTab {
if m.displayState() == StateIdle && k.Type == keyTab {
m.cycleLauncherWf()
return m, nil
}
@@ -219,7 +219,7 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleNormalKey processes vim-style bare-key commands.
func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
ds := m.displayState()
// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm
// gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the
@@ -228,15 +228,15 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleApprovalKey(k)
}
switch k.Type {
case tea.KeyUp:
case keyUp:
m.navUp()
return m, nil
case tea.KeyDown:
case keyDown:
m.navDown()
return m, nil
case tea.KeyEnter:
case keyEnter:
return m.normalEnter()
case tea.KeyEsc:
case keyEsc:
// In-session, esc first drops a transcript selection or output scroll (back to
// tail-follow); otherwise it clears the session-list filter.
if m.transcriptSel >= 0 {
@@ -249,26 +249,26 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
m.filter = ""
return m, nil
case tea.KeyPgUp:
case keyPgUp:
m.scrollOutput(m.outputViewportPage())
return m, nil
case tea.KeyPgDown:
case keyPgDown:
m.scrollOutput(-m.outputViewportPage())
return m, nil
case tea.KeyCtrlU:
case keyCtrlU:
m.scrollOutput(m.outputViewportPage() / 2)
return m, nil
case tea.KeyCtrlD:
case keyCtrlD:
m.scrollOutput(-m.outputViewportPage() / 2)
return m, nil
case tea.KeyCtrlX:
case keyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
}
return m, nil
}
if k.Type != tea.KeyRunes {
if k.Type != keyRunes {
return m, nil
}
switch string(k.Runes) {
@@ -350,18 +350,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// handleApprovalKey owns the approval band's bare keys. Low tiers (T0T2) act on a
// single keypress; a T3+ approve arms first and only a second confirming key sends
// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything.
func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m, nil
}
// Queue navigation never resolves a gate; it just changes which one is shown.
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
s.navApproval(-1)
m.approvalArmed = false
return m, nil
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
s.navApproval(1)
m.approvalArmed = false
return m, nil
@@ -369,7 +369,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
// sending; the same key pressed again (while armed) confirms.
approveKey := k.Type == tea.KeyEnter || k.Type == tea.KeyCtrlA ||
approveKey := k.Type == keyEnter || k.Type == keyCtrlA ||
runeIs(k, "y") || runeIs(k, "a")
if approveKey {
if s.Pending.HighTier() && !m.approvalArmed {
@@ -383,7 +383,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.approvalArmed = false
switch {
case k.Type == tea.KeyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
return m.decide("REJECT")
case runeIs(k, "e") || runeIs(k, "s"):
// Steer/edit flows into the existing steering-note input path.
@@ -396,10 +396,10 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.sessionEntered = false
m.approvalDismissed = false
return m, nil
case k.Type == tea.KeyEsc:
case k.Type == keyEsc:
m.approvalDismissed = true
return m, nil
case k.Type == tea.KeyCtrlX:
case k.Type == keyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
@@ -414,26 +414,26 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleInsertKey processes typing (chat/filter/steer note).
func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.steering {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.steering = false
m.editMode = ModeNormal
case tea.KeyEnter:
case keyEnter:
return m.submitApproval()
case tea.KeyBackspace:
case keyBackspace:
if n := len(m.steerBuffer); n > 0 {
m.steerBuffer = m.steerBuffer[:n-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.steerBuffer += string(k.Runes)
}
return m, nil
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.editMode = ModeNormal
if m.inputMode == ModeFilter {
m.inputMode = ModeRouter
@@ -444,45 +444,46 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.clearInput()
m.inputMode = ModeRouter
}
case tea.KeyEnter:
// Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not
// distinguishable from Enter in this terminal stack — see Ctrl+J below.)
if k.Alt && m.inputMode != ModeFilter {
case keyEnter:
// Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter
// is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation;
// Ctrl+J below stays as a fallback for terminals without kitty support.
if (k.Shift || k.Alt) && m.inputMode != ModeFilter {
m.appendRunes("\n")
return m, nil
}
return m.submit()
case tea.KeyCtrlJ:
case keyCtrlJ:
// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
// single-line filter excluded.
if m.inputMode != ModeFilter {
m.appendRunes("\n")
}
return m, nil
case tea.KeyBackspace:
case keyBackspace:
m.backspace()
m.syncFilter()
case tea.KeyLeft:
case keyLeft:
if m.inputCursor > 0 {
m.inputCursor--
}
case tea.KeyRight:
case keyRight:
if m.inputCursor < len(m.inputBuffer) {
m.inputCursor++
}
case tea.KeyUp:
case keyUp:
if m.inputMode == ModeFilter {
m.listNav(-1)
} else {
m.historyPrev()
}
case tea.KeyDown:
case keyDown:
if m.inputMode == ModeFilter {
m.listNav(1)
} else {
m.historyNext()
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
// opencode-style slash commands: `/` as the first char of a chat line opens the
// command palette inline instead of typing a literal slash. Mid-line `/` is literal.
if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" {
@@ -627,7 +628,7 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
if m.overlay == OverlayConfig {
return m.handleConfigKey(k)
@@ -637,7 +638,7 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.overlay == OverlaySessions {
return m.handleSessionsKey(k)
}
if k.Type == tea.KeyEsc {
if k.Type == keyEsc {
// In the event inspector, esc first stops an in-progress filter edit (keeping the
// query) rather than closing the overlay.
if m.overlay == OverlayEventInspector && m.eventFilterTyping {
@@ -656,37 +657,37 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds.
page := m.diffBodyHeight()
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
m.scrollDiff(-1)
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
m.scrollDiff(1)
case k.Type == tea.KeyPgUp:
case k.Type == keyPgUp:
m.scrollDiff(-page)
case k.Type == tea.KeyPgDown:
case k.Type == keyPgDown:
m.scrollDiff(page)
case k.Type == tea.KeyCtrlU:
case k.Type == keyCtrlU:
m.scrollDiff(-page / 2)
case k.Type == tea.KeyCtrlD:
case k.Type == keyCtrlD:
m.scrollDiff(page / 2)
case runeIs(k, "g"):
m.diffScrollOffset = 0
case runeIs(k, "G"):
m.diffScrollOffset = m.diffMaxScroll()
case k.Type == tea.KeyCtrlX:
case k.Type == keyCtrlX:
m.overlay = OverlayNone
}
case OverlayEventInspector:
evs := m.currentEvents()
if m.eventFilterTyping {
switch {
case k.Type == tea.KeyEnter:
case k.Type == keyEnter:
m.eventFilterTyping = false
case k.Type == tea.KeyBackspace:
case k.Type == keyBackspace:
if n := len(m.eventFilter); n > 0 {
m.eventFilter = m.eventFilter[:n-1]
m.overlayEventIdx = 0
}
case k.Type == tea.KeyRunes || k.Type == tea.KeySpace:
case k.Type == keyRunes || k.Type == keySpace:
m.eventFilter += string(k.Runes)
m.overlayEventIdx = 0
}
@@ -698,11 +699,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "c"):
m.eventFilter = ""
m.overlayEventIdx = 0
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.overlayEventIdx > 0 {
m.overlayEventIdx--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.overlayEventIdx < len(evs)-1 {
m.overlayEventIdx++
}
@@ -712,17 +713,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// dismisses it (esc handled above), keeping the quick-glance feel.
page := m.modalBodyRows()
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
m.scrollModal(-1)
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
m.scrollModal(1)
case k.Type == tea.KeyPgUp:
case k.Type == keyPgUp:
m.scrollModal(-page)
case k.Type == tea.KeyPgDown:
case k.Type == keyPgDown:
m.scrollModal(page)
case k.Type == tea.KeyCtrlU:
case k.Type == keyCtrlU:
m.scrollModal(-page / 2)
case k.Type == tea.KeyCtrlD:
case k.Type == keyCtrlD:
m.scrollModal(page / 2)
case runeIs(k, "g"):
m.modalScroll = 0
@@ -737,23 +738,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case OverlayArtifacts:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.artifactsIndex > 0 {
m.artifactsIndex--
m.artifactScroll = 0
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.artifactsIndex < len(m.artifacts)-1 {
m.artifactsIndex++
m.artifactScroll = 0
}
case k.Type == tea.KeyPgUp:
case k.Type == keyPgUp:
m.scrollArtifact(-m.artifactContentBodyH())
case k.Type == tea.KeyPgDown:
case k.Type == keyPgDown:
m.scrollArtifact(m.artifactContentBodyH())
case k.Type == tea.KeyCtrlU:
case k.Type == keyCtrlU:
m.scrollArtifact(-m.artifactContentBodyH() / 2)
case k.Type == tea.KeyCtrlD:
case k.Type == keyCtrlD:
m.scrollArtifact(m.artifactContentBodyH() / 2)
case runeIs(k, "g"):
m.artifactScroll = 0
@@ -764,15 +765,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case OverlayModels:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.modelsIndex > 0 {
m.modelsIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.modelsIndex < len(m.availableModels)-1 {
m.modelsIndex++
}
case k.Type == tea.KeyEnter:
case k.Type == keyEnter:
if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) {
m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex]))
m.overlay = OverlayNone
@@ -786,17 +787,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case OverlayStats:
page := m.modalBodyRows()
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
m.scrollModal(-1)
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
m.scrollModal(1)
case k.Type == tea.KeyPgUp:
case k.Type == keyPgUp:
m.scrollModal(-page)
case k.Type == tea.KeyPgDown:
case k.Type == keyPgDown:
m.scrollModal(page)
case k.Type == tea.KeyCtrlU:
case k.Type == keyCtrlU:
m.scrollModal(-page / 2)
case k.Type == tea.KeyCtrlD:
case k.Type == keyCtrlD:
m.scrollModal(page / 2)
case runeIs(k, "g"):
m.modalScroll = 0
@@ -807,11 +808,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case OverlayIdeas:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.ideasIndex > 0 {
m.ideasIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.ideasIndex < len(m.ideas)-1 {
m.ideasIndex++
}
@@ -830,39 +831,39 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case OverlayFiles:
files := m.filteredFiles()
switch {
case k.Type == tea.KeyUp:
case k.Type == keyUp:
if m.fileIndex > 0 {
m.fileIndex--
}
case k.Type == tea.KeyDown:
case k.Type == keyDown:
if m.fileIndex < len(files)-1 {
m.fileIndex++
}
case k.Type == tea.KeyEnter:
case k.Type == keyEnter:
if m.fileIndex >= 0 && m.fileIndex < len(files) {
m.insertFileRef(files[m.fileIndex])
}
m.overlay = OverlayNone // back to typing (still in insert mode)
case k.Type == tea.KeyBackspace:
case k.Type == keyBackspace:
if n := len(m.fileFilter); n > 0 {
m.fileFilter = m.fileFilter[:n-1]
m.fileIndex = 0
}
case k.Type == tea.KeyRunes || k.Type == tea.KeySpace:
case k.Type == keyRunes || k.Type == keySpace:
m.fileFilter += string(k.Runes)
m.fileIndex = 0
}
case OverlayStatusbar:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.sbIndex > 0 {
m.sbIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.sbIndex < len(statusSegments)-1 {
m.sbIndex++
}
case k.Type == tea.KeySpace || k.Type == tea.KeyEnter || runeIs(k, "x"):
case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"):
if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) {
m.toggleStatusSegment(statusSegments[m.sbIndex].id)
}
@@ -871,15 +872,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case OverlayGrants:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.grantIndex > 0 {
m.grantIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.grantIndex < len(m.grants)-1 {
m.grantIndex++
}
case k.Type == tea.KeyEnter || runeIs(k, "x"):
case k.Type == keyEnter || runeIs(k, "x"):
if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
g := m.grants[m.grantIndex]
m.client.Send(protocol.RevokeGrant(g.GrantID))
@@ -910,14 +911,14 @@ func grantScopeOptions() []grantScopeOption {
// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) {
opts := grantScopeOptions()
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.grantScopeIndex > 0 {
m.grantScopeIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.grantScopeIndex < len(opts)-1 {
m.grantScopeIndex++
}
@@ -930,7 +931,7 @@ func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "g"):
m.grantScopeIndex = 2
return m.applyGrantScope()
case k.Type == tea.KeyEnter:
case k.Type == keyEnter:
return m.applyGrantScope()
}
return m, nil
@@ -1046,31 +1047,31 @@ func (m *Model) openModelsOverlay() {
}
}
func runeIs(k tea.KeyMsg, s string) bool {
return k.Type == tea.KeyRunes && string(k.Runes) == s
func runeIs(k keyMsg, s string) bool {
return k.Type == keyRunes && string(k.Runes) == s
}
func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) {
cmds := m.filteredPalette()
switch k.Type {
case tea.KeyUp:
case keyUp:
if m.paletteIndex > 0 {
m.paletteIndex--
}
case tea.KeyDown:
case keyDown:
if m.paletteIndex < len(cmds)-1 {
m.paletteIndex++
}
case tea.KeyEnter:
case keyEnter:
if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) {
return m.execPalette(cmds[m.paletteIndex].id)
}
case tea.KeyBackspace:
case keyBackspace:
if n := len(m.paletteFilter); n > 0 {
m.paletteFilter = m.paletteFilter[:n-1]
m.paletteIndex = 0
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.paletteFilter += string(k.Runes)
m.paletteIndex = 0
}
+21 -12
View File
@@ -2,11 +2,13 @@ package app
import (
"fmt"
"image/color"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -28,8 +30,15 @@ const (
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }
// View renders the whole frame purely from the model (immediate-mode).
func (m Model) View() string {
// View renders the whole frame purely from the model (immediate-mode). In
// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so
// render() builds the string and View() wraps it with the screen settings.
func (m Model) View() tea.View {
return tea.View{Content: m.render(), AltScreen: true}
}
// render builds the frame string.
func (m Model) render() string {
if m.quitting {
return ""
}
@@ -79,7 +88,7 @@ func (m Model) View() string {
func (m Model) renderStatus() string {
t := m.theme
bg := t.P.BgPanel
span := func(s string, fg lipgloss.Color) string {
span := func(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
}
nrw := m.narrow()
@@ -376,12 +385,12 @@ func (m Model) renderLauncher(w, h int) string {
inW = 24
}
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
lipgloss.WithWhitespaceBackground(t.P.Bg))
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
if railW == 0 {
return left
}
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
lipgloss.WithWhitespaceBackground(t.P.Bg))
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
}
@@ -738,7 +747,7 @@ func (m Model) eventStreamBadge() string {
return t.span("○ idle", t.P.Faint)
}
if s := m.session(m.selectedID); s != nil {
badge := func(fg lipgloss.Color, text string) string {
badge := func(fg color.Color, text string) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
}
switch u := strings.ToUpper(s.Status); {
@@ -764,7 +773,7 @@ func shortPath(p string) string {
}
// fill left-justifies a styled line and pads with bg to width w.
func (m Model) fill(s string, w int, bg lipgloss.Color) string {
func (m Model) fill(s string, w int, bg color.Color) string {
return " " + padTo(s, w-1, bg)
}
@@ -772,7 +781,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string {
// so the line never overflows w. The left segment is shrunk first (the right holds
// the high-signal status — connection clock / active spinner); the right is clamped
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
func (m Model) justify(left, right string, w int, bg color.Color) string {
avail := w - 2 // leading + trailing space
lw := lipgloss.Width(left)
rw := lipgloss.Width(right)
@@ -798,7 +807,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque.
func padTo(s string, w int, bg lipgloss.Color) string {
func padTo(s string, w int, bg color.Color) string {
vw := lipgloss.Width(s)
if vw == w {
return s
@@ -825,7 +834,7 @@ func padRaw(s string, w int) string {
return s + strings.Repeat(" ", w-n)
}
func statusColor(t Theme, status string) lipgloss.Color {
func statusColor(t Theme, status string) color.Color {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
@@ -862,7 +871,7 @@ func statusGlyph(t Theme, status string) string {
}
// span renders text with foreground fg over the panel background.
func (t Theme) span(s string, fg lipgloss.Color) string {
func (t Theme) span(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
}