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
+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
}