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