mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(tui): Ctrl+X leader chords and emacs menu navigation Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ? chord map), and Ctrl+P/N previous/next selection in modals and pickers. * fix(tui): drop Ctrl+X e /edit leader chord /edit replaces the composer and would discard an in-progress draft; leave it unbound so leader shortcuts stay draft-preserving. * fix(tui): consume idle Ctrl+N as a no-op Reserve Ctrl+N for emacs menu navigation so it never falls through to remapped global bindings when no selection surface is open. * docs: drop manual CHANGELOG entries for TUI shortcuts
299 lines
11 KiB
Go
299 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
func TestQuestionMarkOpensHelpOnEmptyComposer(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
updated, _ := m.Update(testKeyText("?"))
|
|
next := updated.(model)
|
|
if !next.helpOverlay {
|
|
t.Fatal("? on an empty composer should open the help overlay")
|
|
}
|
|
// The ? must NOT have been typed into the composer.
|
|
if next.composerValue() != "" {
|
|
t.Fatalf("composer should stay empty when ? opens help, got %q", next.composerValue())
|
|
}
|
|
}
|
|
|
|
func TestQuestionMarkTypesIntoNonEmptyComposer(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m = typeRunes(t, m, "what")
|
|
updated, _ := m.Update(testKeyText("?"))
|
|
next := updated.(model)
|
|
if next.helpOverlay {
|
|
t.Fatal("? after text should type a literal '?', not open help")
|
|
}
|
|
if got := next.composerValue(); got != "what?" {
|
|
t.Fatalf("composer = %q, want %q", got, "what?")
|
|
}
|
|
}
|
|
|
|
func TestHelpOverlayClosesOnQuestionMarkAndEsc(t *testing.T) {
|
|
for _, closer := range []struct {
|
|
name string
|
|
key tea.KeyPressMsg
|
|
}{
|
|
{"question-mark", testKeyText("?")},
|
|
{"esc", testKey(tea.KeyEsc)},
|
|
{"q", testKeyText("q")},
|
|
{"enter", testKey(tea.KeyEnter)},
|
|
} {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.helpOverlay = true
|
|
updated, _ := m.Update(closer.key)
|
|
next := updated.(model)
|
|
if next.helpOverlay {
|
|
t.Fatalf("%s should close the help overlay", closer.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHelpOverlaySwallowsOtherKeys(t *testing.T) {
|
|
// While the overlay is open, an ordinary key must not type into the composer.
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.helpOverlay = true
|
|
updated, _ := m.Update(testKeyText("x"))
|
|
next := updated.(model)
|
|
if !next.helpOverlay {
|
|
t.Fatal("an ordinary key should not close the overlay")
|
|
}
|
|
if next.composerValue() != "" {
|
|
t.Fatalf("keys must be swallowed while the overlay is open, composer = %q", next.composerValue())
|
|
}
|
|
}
|
|
|
|
func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.width = 90
|
|
m.height = 40
|
|
m.helpOverlay = true
|
|
view := plainRender(t, m.View())
|
|
for _, want := range []string{
|
|
"Keyboard Shortcuts",
|
|
"Ctrl+T", "cycle reasoning effort",
|
|
"Shift+Tab", "Ctrl+P", "Ctrl+O",
|
|
"drill into its sub-session",
|
|
"Ctrl+X then letter", "/model",
|
|
keybindingHelpFooter,
|
|
} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("help overlay view missing %q, got:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildKeybindingGroupsAreWellFormed(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
groups := m.buildKeybindingGroups()
|
|
if len(groups) == 0 {
|
|
t.Fatal("buildKeybindingGroups must not return empty")
|
|
}
|
|
for _, group := range groups {
|
|
if strings.TrimSpace(group.title) == "" {
|
|
t.Fatal("every keybinding group needs a title")
|
|
}
|
|
if len(group.bindings) == 0 {
|
|
t.Fatalf("group %q has no bindings", group.title)
|
|
}
|
|
for _, binding := range group.bindings {
|
|
if strings.TrimSpace(binding.keys) == "" || strings.TrimSpace(binding.desc) == "" {
|
|
t.Fatalf("group %q has a binding with an empty key or description: %+v", group.title, binding)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// #419: the `?` help overlay must render ON TOP of the chat (like the model
|
|
// picker), not REPLACE the whole screen. The old full-screen replace produced
|
|
// only the centered shortcut block on a blank canvas — no title bar, no
|
|
// composer. With the overlay open, surrounding chat chrome that is NOT covered
|
|
// by the centered box (the model title bar at top, the composer at bottom) must
|
|
// still be present alongside "Keyboard Shortcuts".
|
|
func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.width = 100
|
|
m.height = 40
|
|
m.altScreen = true
|
|
|
|
base := plainRender(t, m.View()) // no overlay: baseline chrome
|
|
m.helpOverlay = true
|
|
over := plainRender(t, m.View())
|
|
|
|
if !strings.Contains(over, "Keyboard Shortcuts") {
|
|
t.Fatalf("help overlay not rendered:\n%s", over)
|
|
}
|
|
// Chrome that renders in the baseline (and sits outside the centered overlay
|
|
// box) must survive behind the overlay. The full-screen replace showed none.
|
|
for _, marker := range []string{"gpt-4o", "describe a task"} {
|
|
if !strings.Contains(base, marker) {
|
|
t.Fatalf("precondition: baseline chat should contain %q:\n%s", marker, base)
|
|
}
|
|
if !strings.Contains(over, marker) {
|
|
t.Fatalf("#419: help replaced the chat instead of overlaying it; %q is gone:\n%s", marker, over)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A populated transcript row also survives behind the overlay (peeking out to
|
|
// the left of the centered box), proving the chat body — not just the chrome —
|
|
// is composited under the overlay rather than discarded.
|
|
func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.width = 120
|
|
m.height = 40
|
|
m.altScreen = true
|
|
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello there this is a chat line"})
|
|
m.helpOverlay = true
|
|
|
|
view := plainRender(t, m.View())
|
|
if !strings.Contains(view, "Keyboard Shortcuts") {
|
|
t.Fatalf("help overlay not rendered:\n%s", view)
|
|
}
|
|
// The start of the transcript line peeks to the left of the centered box.
|
|
if !strings.Contains(view, "hello") {
|
|
t.Fatalf("#419: transcript body was replaced by the help overlay:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
|
|
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.altScreen = true
|
|
m.width = 120
|
|
m.height = 40
|
|
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
|
if !m.sidebarToggleAllowed() {
|
|
t.Fatal("sidebar toggle should be allowed")
|
|
}
|
|
|
|
initialSidebar := m.sidebarHidden
|
|
updated, _ := m.Update(testKeyCtrl('b'))
|
|
next := updated.(model)
|
|
if next.sidebarHidden == initialSidebar {
|
|
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
|
|
}
|
|
|
|
initialMouse := next.mouseReleased
|
|
updated, _ = next.Update(testKeyCtrl('e'))
|
|
next = updated.(model)
|
|
if next.mouseReleased == initialMouse {
|
|
t.Fatal("Ctrl+E on empty composer should toggle mouseReleased")
|
|
}
|
|
|
|
// 2. Non-empty composer: Ctrl+B and Ctrl+E should not toggle state, but act as cursor movement
|
|
m2 := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m2.altScreen = true
|
|
m2.width = 120
|
|
m2.height = 40
|
|
m2.transcript = append(m2.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
|
|
|
m2 = typeRunes(t, m2, "hello")
|
|
if m2.composerValue() != "hello" {
|
|
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
|
|
}
|
|
|
|
initialSidebar2 := m2.sidebarHidden
|
|
updated, _ = m2.Update(testKeyCtrl('b'))
|
|
next2 := updated.(model)
|
|
if next2.sidebarHidden != initialSidebar2 {
|
|
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
|
|
}
|
|
|
|
initialMouse2 := next2.mouseReleased
|
|
updated, _ = next2.Update(testKeyCtrl('e'))
|
|
next2 = updated.(model)
|
|
if next2.mouseReleased != initialMouse2 {
|
|
t.Fatal("Ctrl+E on non-empty composer should NOT toggle mouseReleased")
|
|
}
|
|
}
|
|
|
|
// TestRemappedToggleBindingsIgnoreComposerGuard guards against the
|
|
// composer-empty bypass over-applying to a user-remapped, non-conflicting
|
|
// binding: it exists so the default Ctrl+E/Ctrl+B chords (which readline
|
|
// navigation also claims while typing) don't hijack keystrokes mid-sentence,
|
|
// not to block every toggleMouse/toggleSidebar binding while composing.
|
|
func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.altScreen = true
|
|
m.width = 120
|
|
m.height = 40
|
|
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
|
m.keyBindings.toggleMouse = parseBinding("ctrl+m")
|
|
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
|
|
// reaches configurable global bindings.
|
|
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
|
|
if !m.sidebarToggleAllowed() {
|
|
t.Fatal("sidebar toggle should be allowed")
|
|
}
|
|
|
|
m = typeRunes(t, m, "hello")
|
|
if m.composerValue() != "hello" {
|
|
t.Fatalf("composerValue = %q, want 'hello'", m.composerValue())
|
|
}
|
|
|
|
initialMouse := m.mouseReleased
|
|
updated, _ := m.Update(testKeyCtrl('m'))
|
|
next := updated.(model)
|
|
if next.mouseReleased == initialMouse {
|
|
t.Fatal("remapped Ctrl+M toggleMouse binding should still fire with a non-empty composer")
|
|
}
|
|
if next.composerValue() != "hello" {
|
|
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
|
|
}
|
|
|
|
initialSidebar := next.sidebarHidden
|
|
updated, _ = next.Update(testKeyCtrl('y'))
|
|
next = updated.(model)
|
|
if next.sidebarHidden == initialSidebar {
|
|
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
|
|
}
|
|
if next.composerValue() != "hello" {
|
|
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
|
|
}
|
|
}
|
|
|
|
// TestExplicitDefaultChordConfigStillRequiresEmptyComposer guards against
|
|
// treating "explicitly configured" the same as "genuinely remapped": a user
|
|
// who writes toggleMouse: "ctrl+e" / toggleSidebar: "ctrl+b" in config (the
|
|
// same chord as the built-in default) gets a non-zero parsedBinding, not the
|
|
// isZero() sentinel, so a check that only asked "is this unset" would wrongly
|
|
// let Ctrl+E/Ctrl+B bypass the composer guard again instead of reaching the
|
|
// readline cursor-navigation handlers.
|
|
func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
|
|
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
|
m.altScreen = true
|
|
m.width = 120
|
|
m.height = 40
|
|
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
|
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
|
|
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
|
|
if !m.sidebarToggleAllowed() {
|
|
t.Fatal("sidebar toggle should be allowed")
|
|
}
|
|
|
|
m = typeRunes(t, m, "hello")
|
|
if m.composerValue() != "hello" {
|
|
t.Fatalf("composerValue = %q, want 'hello'", m.composerValue())
|
|
}
|
|
|
|
initialMouse := m.mouseReleased
|
|
updated, _ := m.Update(testKeyCtrl('e'))
|
|
next := updated.(model)
|
|
if next.mouseReleased != initialMouse {
|
|
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
|
|
}
|
|
|
|
initialSidebar := next.sidebarHidden
|
|
updated, _ = next.Update(testKeyCtrl('b'))
|
|
next = updated.(model)
|
|
if next.sidebarHidden != initialSidebar {
|
|
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
|
|
}
|
|
}
|