1033 lines
25 KiB
Go
1033 lines
25 KiB
Go
//go:build !windows
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/maximhq/vt10x"
|
|
)
|
|
|
|
func TestAddPendingTabDisablesCommandMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
tabs: []*Tab{{id: 1, label: "Codex"}},
|
|
rows: 24,
|
|
cols: 80,
|
|
}
|
|
tm.activeIdx = 0
|
|
tm.nextID = 2
|
|
tm.commandMode = true
|
|
|
|
tab, prevActive := tm.addPendingTab()
|
|
|
|
if prevActive != 0 {
|
|
t.Fatalf("addPendingTab() prevActive = %d, want 0", prevActive)
|
|
}
|
|
if tab.label != pendingTabLabel {
|
|
t.Fatalf("addPendingTab() label = %q, want %q", tab.label, pendingTabLabel)
|
|
}
|
|
if tm.activeIdx != 1 {
|
|
t.Fatalf("activeIdx = %d, want 1", tm.activeIdx)
|
|
}
|
|
if tm.commandMode {
|
|
t.Fatal("expected command mode to be disabled")
|
|
}
|
|
if !tm.paused {
|
|
t.Fatal("expected tab manager to be paused while chooser is active")
|
|
}
|
|
}
|
|
|
|
func TestRemovePendingTabRestoresPreviousActive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
original := &Tab{id: 1, label: "Codex"}
|
|
tm := &TabManager{
|
|
tabs: []*Tab{original},
|
|
rows: 24,
|
|
cols: 80,
|
|
}
|
|
tm.activeIdx = 0
|
|
tm.nextID = 2
|
|
|
|
pending, prevActive := tm.addPendingTab()
|
|
active := tm.removePendingTab(pending, prevActive)
|
|
|
|
if active != original {
|
|
t.Fatal("expected original tab to be restored after removing pending tab")
|
|
}
|
|
if len(tm.tabs) != 1 {
|
|
t.Fatalf("len(tabs) = %d, want 1", len(tm.tabs))
|
|
}
|
|
if tm.activeIdx != 0 {
|
|
t.Fatalf("activeIdx = %d, want 0", tm.activeIdx)
|
|
}
|
|
if tm.paused {
|
|
t.Fatal("expected paused to be cleared after removing pending tab")
|
|
}
|
|
if tm.commandMode {
|
|
t.Fatal("expected command mode to stay disabled after removing pending tab")
|
|
}
|
|
}
|
|
|
|
func TestShouldExitWithoutTabsRespectsCommandMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{}
|
|
if !tm.shouldExitWithoutTabs() {
|
|
t.Fatal("expected empty manager outside command mode to exit")
|
|
}
|
|
|
|
tm.commandMode = true
|
|
if tm.shouldExitWithoutTabs() {
|
|
t.Fatal("did not expect empty manager in command mode to exit")
|
|
}
|
|
|
|
tm.tabs = []*Tab{{id: 1, label: "Home"}}
|
|
if tm.shouldExitWithoutTabs() {
|
|
t.Fatal("did not expect manager with tabs to exit")
|
|
}
|
|
}
|
|
|
|
func TestHandleCommandKeyKeepsHomeCommandModeWithoutTabs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
stdout: io.Discard,
|
|
rows: 24,
|
|
cols: 80,
|
|
commandMode: true,
|
|
}
|
|
|
|
tm.handleCommandKey(nil, nil, nil, prefix)
|
|
|
|
if !tm.commandMode {
|
|
t.Fatal("expected command mode to stay active when there are no tabs to resume")
|
|
}
|
|
}
|
|
|
|
func TestHandleCommandKeyEscapeReopensChooserWhenNoTabs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var called bool
|
|
var out bytes.Buffer
|
|
tm := &TabManager{
|
|
stdout: &out,
|
|
rows: 24,
|
|
cols: 80,
|
|
commandMode: true,
|
|
}
|
|
|
|
err := tm.handleCommandKey(context.Background(), func(ctx context.Context, notify func(TabNoticeLevel, string), tabBarLine func() string, stdinReader io.Reader, seed *LaunchSpec) (*LaunchSpec, error) {
|
|
called = true
|
|
return nil, nil
|
|
}, nil, 0x1b)
|
|
if err != nil {
|
|
t.Fatalf("handleCommandKey() error = %v", err)
|
|
}
|
|
if !called {
|
|
t.Fatal("expected escape with no tabs to reopen chooser")
|
|
}
|
|
if tm.commandMode {
|
|
t.Fatal("expected command mode to exit when reopening chooser")
|
|
}
|
|
}
|
|
|
|
func TestEnterCommandModeDrawsHomeTabBarWithoutTabs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var out bytes.Buffer
|
|
tm := &TabManager{
|
|
stdout: &out,
|
|
rows: 24,
|
|
cols: 80,
|
|
}
|
|
|
|
tm.enterCommandMode()
|
|
|
|
if !tm.commandMode {
|
|
t.Fatal("expected command mode to be enabled")
|
|
}
|
|
if got := out.String(); !strings.Contains(got, "\x1b[2J\x1b[H") {
|
|
t.Fatalf("expected home command mode to clear stale chooser content, got %q", got)
|
|
}
|
|
if got := out.String(); got == "" || !bytes.Contains(out.Bytes(), []byte("n:new")) {
|
|
t.Fatalf("expected home tab bar command hints to be rendered, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDrawTabBarResetsOriginAndScrollRegion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var out bytes.Buffer
|
|
tm := &TabManager{
|
|
stdout: &out,
|
|
rows: 24,
|
|
cols: 80,
|
|
}
|
|
|
|
tm.drawTabBar()
|
|
|
|
got := out.String()
|
|
if !strings.Contains(got, "\x1b[r") {
|
|
t.Fatalf("expected drawTabBar() to reset scroll region, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "\x1b[?6l") {
|
|
t.Fatalf("expected drawTabBar() to reset origin mode, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEditSessionKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !isEditSessionKey('e') {
|
|
t.Fatal("expected lowercase e to edit the current session")
|
|
}
|
|
if !isEditSessionKey('E') {
|
|
t.Fatal("expected uppercase E to edit the current session")
|
|
}
|
|
if isEditSessionKey('n') {
|
|
t.Fatal("did not expect n to be treated as edit session")
|
|
}
|
|
}
|
|
|
|
func TestBuildTabBarStringUsesCLIBrandAndRightSideVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
version: "0.1.1-dev",
|
|
rows: 24,
|
|
cols: 120,
|
|
}
|
|
|
|
got := tm.buildTabBarString()
|
|
|
|
if !strings.Contains(got, "Bifrost CLI") {
|
|
t.Fatalf("expected tab bar to contain Bifrost CLI branding, got %q", got)
|
|
}
|
|
if strings.Contains(got, " Bifrost ") {
|
|
t.Fatalf("did not expect legacy Bifrost branding, got %q", got)
|
|
}
|
|
if strings.Contains(got, "▣") {
|
|
t.Fatalf("did not expect tab bar logo glyph, got %q", got)
|
|
}
|
|
if !strings.Contains(got, " v0.1.1-dev ") {
|
|
t.Fatalf("expected tab bar to contain right-side version label, got %q", got)
|
|
}
|
|
if strings.Contains(got, " vv0.1.1-dev ") {
|
|
t.Fatalf("did not expect duplicated version prefix, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildTabBarStringShowsErrorNoticeInRed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
rows: 24,
|
|
cols: 120,
|
|
commandMode: true,
|
|
noticeText: "new tab failed",
|
|
noticeLevel: TabNoticeError,
|
|
}
|
|
|
|
got := tm.buildTabBarString()
|
|
|
|
if !strings.Contains(got, "\x1b[48;5;88m") {
|
|
t.Fatalf("expected red error background, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "error: new tab failed") {
|
|
t.Fatalf("expected error message in tab bar, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "Esc: clear") {
|
|
t.Fatalf("expected escape-to-clear hint, got %q", got)
|
|
}
|
|
if strings.Contains(got, "space: resume") {
|
|
t.Fatalf("did not expect space-to-resume hint during sticky error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildTabBarStringShowsEditSessionHintInCommandMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
rows: 24,
|
|
cols: 120,
|
|
commandMode: true,
|
|
tabs: []*Tab{
|
|
{id: 1, label: "Codex"},
|
|
},
|
|
}
|
|
|
|
got := tm.buildTabBarString()
|
|
|
|
if !strings.Contains(got, "e:edit session") {
|
|
t.Fatalf("expected command mode hint to include edit session, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHostInputModeSequenceEnablesMouseAndFocus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := hostInputModeSequence(vt10x.ModeMouseMotion | vt10x.ModeMouseSgr | vt10x.ModeFocus)
|
|
|
|
if !strings.Contains(got, "\x1b[?1002h") {
|
|
t.Fatalf("expected motion mouse tracking enable, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "\x1b[?1006h") {
|
|
t.Fatalf("expected sgr mouse tracking enable, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "\x1b[?1004h") {
|
|
t.Fatalf("expected focus tracking enable, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHostKeyboardResetSequenceDisablesEnhancedKeyboardModes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := hostKeyboardResetSequence()
|
|
|
|
if got != "\x1b[<u" {
|
|
t.Fatalf("hostKeyboardResetSequence() = %q, want %q", got, "\x1b[<u")
|
|
}
|
|
for _, unwanted := range []string{
|
|
"\x1b[>0n",
|
|
"\x1b[>1n",
|
|
"\x1b[>2n",
|
|
"\x1b[>3n",
|
|
"\x1b[>4n",
|
|
"\x1b[>6n",
|
|
"\x1b[>7n",
|
|
} {
|
|
if strings.Contains(got, unwanted) {
|
|
t.Fatalf("did not expect keyboard reset sequence %q in %q", unwanted, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHostCursorResetSequenceShowsVisibleDefaultCursor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := hostCursorResetSequence()
|
|
|
|
if got != "\x1b[0 q\x1b[?25h" {
|
|
t.Fatalf("hostCursorResetSequence() = %q, want %q", got, "\x1b[0 q\x1b[?25h")
|
|
}
|
|
}
|
|
|
|
func TestSyncHostInputModesReturnsSequenceOnlyOnChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{}
|
|
mode := vt10x.ModeMouseButton | vt10x.ModeMouseSgr
|
|
|
|
first := tm.syncHostInputModes(mode)
|
|
if first == "" {
|
|
t.Fatal("expected first host input mode sync to emit escape sequence")
|
|
}
|
|
second := tm.syncHostInputModes(mode)
|
|
if second != "" {
|
|
t.Fatalf("expected unchanged host input mode sync to emit nothing, got %q", second)
|
|
}
|
|
reset := tm.syncHostInputModes(0)
|
|
if !strings.Contains(reset, "\x1b[?1000l") {
|
|
t.Fatalf("expected reset sequence to disable mouse tracking, got %q", reset)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTerminalSizeClampsTinyResize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cols, rows := normalizeTerminalSize(0, 1)
|
|
if cols != 20 || rows != 2 {
|
|
t.Fatalf("normalizeTerminalSize(0, 1) = (%d, %d), want (20, 2)", cols, rows)
|
|
}
|
|
}
|
|
|
|
func TestResetHostInputModesRestoresCursorVisibility(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var out bytes.Buffer
|
|
tm := &TabManager{stdout: &out}
|
|
|
|
tm.resetHostInputModes()
|
|
|
|
if got := out.String(); !strings.Contains(got, "\x1b[?25h") {
|
|
t.Fatalf("expected resetHostInputModes() to restore visible cursor, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleCommandKeySpaceClearsNoticeAndResumes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
stdout: io.Discard,
|
|
rows: 24,
|
|
cols: 80,
|
|
commandMode: true,
|
|
noticeText: "oops",
|
|
noticeLevel: TabNoticeError,
|
|
tabs: []*Tab{{id: 1, label: "Codex"}},
|
|
}
|
|
|
|
tm.handleCommandKey(nil, nil, nil, ' ')
|
|
|
|
if tm.commandMode {
|
|
t.Fatal("expected command mode to exit after space resume")
|
|
}
|
|
if tm.noticeText != "" {
|
|
t.Fatalf("expected notice to clear after space resume, got %q", tm.noticeText)
|
|
}
|
|
}
|
|
|
|
func TestHandleCommandKeyEscapeClearsStickyErrorAndStaysInCommandMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
stdout: io.Discard,
|
|
rows: 24,
|
|
cols: 80,
|
|
commandMode: true,
|
|
noticeText: "oops",
|
|
noticeLevel: TabNoticeError,
|
|
noticeSticky: true,
|
|
tabs: []*Tab{{id: 1, label: "Codex"}},
|
|
}
|
|
|
|
tm.handleCommandKey(nil, nil, nil, 0x1b)
|
|
|
|
if !tm.commandMode {
|
|
t.Fatal("expected command mode to stay enabled after clearing sticky error")
|
|
}
|
|
if tm.noticeText != "" {
|
|
t.Fatalf("expected sticky error notice to clear on escape, got %q", tm.noticeText)
|
|
}
|
|
}
|
|
|
|
func TestHandleCommandKeyEnterDoesNotResumeWhileStickyErrorIsShown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{
|
|
stdout: io.Discard,
|
|
rows: 24,
|
|
cols: 80,
|
|
commandMode: true,
|
|
noticeText: "oops",
|
|
noticeLevel: TabNoticeError,
|
|
noticeSticky: true,
|
|
tabs: []*Tab{{id: 1, label: "Codex"}},
|
|
}
|
|
|
|
tm.handleCommandKey(nil, nil, nil, '\r')
|
|
|
|
if !tm.commandMode {
|
|
t.Fatal("expected sticky error to keep tab manager in command mode")
|
|
}
|
|
if tm.noticeText != "oops" {
|
|
t.Fatalf("expected enter to leave sticky error untouched, got %q", tm.noticeText)
|
|
}
|
|
}
|
|
|
|
func TestNoteCtrlCDoublePressRequestsClose(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{}
|
|
now := time.Now()
|
|
|
|
if tm.noteCtrlC(now) {
|
|
t.Fatal("did not expect first ctrl+c to force close")
|
|
}
|
|
if !tm.noteCtrlC(now.Add(time.Second)) {
|
|
t.Fatal("expected second ctrl+c within window to force close")
|
|
}
|
|
}
|
|
|
|
func TestNoteCtrlCOutsideWindowDoesNotRequestClose(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{}
|
|
now := time.Now()
|
|
|
|
if tm.noteCtrlC(now) {
|
|
t.Fatal("did not expect first ctrl+c to force close")
|
|
}
|
|
if tm.noteCtrlC(now.Add(tabCtrlCExitWindow + time.Millisecond)) {
|
|
t.Fatal("did not expect ctrl+c outside window to force close")
|
|
}
|
|
}
|
|
|
|
func TestHandleActiveCtrlCResetsOnOtherInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tm := &TabManager{}
|
|
|
|
if tm.handleActiveCtrlC([]byte{0x03}) {
|
|
t.Fatal("did not expect first ctrl+c to close tab")
|
|
}
|
|
if tm.handleActiveCtrlC([]byte("a")) {
|
|
t.Fatal("did not expect regular input to close tab")
|
|
}
|
|
if tm.handleActiveCtrlC([]byte{0x03}) {
|
|
t.Fatal("did not expect ctrl+c after other input to close tab")
|
|
}
|
|
}
|
|
|
|
func TestModifierHasCtrlSupportsColonSuffix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !modifierHasCtrl("5:3") {
|
|
t.Fatal("expected ctrl modifier to be detected from colon-suffixed value")
|
|
}
|
|
|
|
if modifierHasCtrl("1:3") {
|
|
t.Fatal("did not expect ctrl modifier in plain colon-suffixed value")
|
|
}
|
|
}
|
|
|
|
func TestDecodeCommandByteCSIU(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
seq string
|
|
want byte
|
|
}{
|
|
{
|
|
name: "printable key",
|
|
seq: "\x1b[110;1:1u",
|
|
want: 'n',
|
|
},
|
|
{
|
|
name: "escape key",
|
|
seq: "\x1b[27;1:1u",
|
|
want: 0x1b,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, ok := decodeCommandByte([]byte(tc.seq))
|
|
if !ok {
|
|
t.Fatalf("expected sequence %q to decode", tc.seq)
|
|
}
|
|
if got != tc.want {
|
|
t.Fatalf("decodeCommandByte(%q) = %q, want %q", tc.seq, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsCtrlBSequenceSupportsColonSuffix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Press event with colon-suffixed modifier (event_type 1) should match.
|
|
if !isCtrlBSequence([]byte("\x1b[98;5:1u")) {
|
|
t.Fatal("expected ctrl+b press event with colon-suffixed modifiers to be recognized")
|
|
}
|
|
|
|
// Release event (event_type 3) should NOT match — release events must be
|
|
// silently dropped, otherwise command mode enters and exits instantly.
|
|
if isCtrlBSequence([]byte("\x1b[98;5:3u")) {
|
|
t.Fatal("expected ctrl+b release event to be rejected")
|
|
}
|
|
|
|
// Repeat event (event_type 2) should match.
|
|
if !isCtrlBSequence([]byte("\x1b[98;5:2u")) {
|
|
t.Fatal("expected ctrl+b repeat event to be recognized")
|
|
}
|
|
}
|
|
|
|
func TestPrefersFullscreenChooserAppleTerminal(t *testing.T) {
|
|
old := os.Getenv("TERM_PROGRAM")
|
|
t.Cleanup(func() {
|
|
if old == "" {
|
|
os.Unsetenv("TERM_PROGRAM")
|
|
return
|
|
}
|
|
os.Setenv("TERM_PROGRAM", old)
|
|
})
|
|
|
|
os.Setenv("TERM_PROGRAM", "Apple_Terminal")
|
|
if !prefersFullscreenChooser() {
|
|
t.Fatal("expected Apple Terminal to use fullscreen chooser fallback")
|
|
}
|
|
|
|
os.Setenv("TERM_PROGRAM", "iTerm.app")
|
|
if prefersFullscreenChooser() {
|
|
t.Fatal("did not expect iTerm to use fullscreen chooser fallback")
|
|
}
|
|
}
|
|
|
|
func TestNextInputTokenParsesDCSReply(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seq := []byte("\x1bP>|iTerm2 3.6.664\x1b\\")
|
|
|
|
token, consumed, isPrefix, complete := nextInputToken(seq)
|
|
|
|
if !complete {
|
|
t.Fatal("expected DCS reply to parse as a complete token")
|
|
}
|
|
if isPrefix {
|
|
t.Fatal("did not expect DCS reply to be treated as the tab-mode prefix")
|
|
}
|
|
if consumed != len(seq) {
|
|
t.Fatalf("consumed = %d, want %d", consumed, len(seq))
|
|
}
|
|
if string(token) != string(seq) {
|
|
t.Fatalf("token = %q, want %q", token, seq)
|
|
}
|
|
}
|
|
|
|
func TestNextInputTokenMarksPartialDCSIncomplete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
token, consumed, isPrefix, complete := nextInputToken([]byte("\x1bP>|iTerm2"))
|
|
|
|
if complete {
|
|
t.Fatal("expected partial DCS reply to remain incomplete")
|
|
}
|
|
if token != nil {
|
|
t.Fatalf("token = %q, want nil", token)
|
|
}
|
|
if consumed != 0 {
|
|
t.Fatalf("consumed = %d, want 0", consumed)
|
|
}
|
|
if isPrefix {
|
|
t.Fatal("did not expect partial DCS reply to be treated as a prefix")
|
|
}
|
|
}
|
|
|
|
func TestNextInputTokenParsesX10MouseReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
token, consumed, isPrefix, complete := nextInputToken([]byte("\x1b[M !!"))
|
|
|
|
if !complete {
|
|
t.Fatal("expected X10 mouse report to parse as a complete token")
|
|
}
|
|
if isPrefix {
|
|
t.Fatal("did not expect X10 mouse report to be treated as a prefix")
|
|
}
|
|
if consumed != 6 {
|
|
t.Fatalf("consumed = %d, want 6", consumed)
|
|
}
|
|
if string(token) != "\x1b[M !!" {
|
|
t.Fatalf("token = %q, want %q", token, "\x1b[M !!")
|
|
}
|
|
}
|
|
|
|
func TestParseMouseEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("parses sgr left click", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ev, ok := parseMouseEvent([]byte("\x1b[<0;18;24M"))
|
|
if !ok {
|
|
t.Fatal("expected SGR mouse event to parse")
|
|
}
|
|
if ev.x != 18 || ev.y != 24 || ev.button != 0 || !ev.press || ev.motion || ev.wheel {
|
|
t.Fatalf("unexpected SGR mouse event: %+v", ev)
|
|
}
|
|
})
|
|
|
|
t.Run("parses x10 left click", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ev, ok := parseMouseEvent([]byte("\x1b[M +8"))
|
|
if !ok {
|
|
t.Fatal("expected X10 mouse event to parse")
|
|
}
|
|
if ev.x != 11 || ev.y != 24 || ev.button != 0 || !ev.press || ev.motion || ev.wheel {
|
|
t.Fatalf("unexpected X10 mouse event: %+v", ev)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects release events as clicks", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ev, ok := parseMouseEvent([]byte("\x1b[<0;18;24m"))
|
|
if !ok {
|
|
t.Fatal("expected SGR mouse release to parse")
|
|
}
|
|
if ev.press {
|
|
t.Fatalf("expected release event, got %+v", ev)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandleTabBarMouseEventSwitchesTabs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
first := &Tab{label: "Codex", startedAt: time.Now().Add(-tabStartingWindow - time.Second), vt: vt10x.New(vt10x.WithSize(80, 24))}
|
|
second := &Tab{label: "Gemini", startedAt: time.Now().Add(-tabStartingWindow - time.Second), vt: vt10x.New(vt10x.WithSize(80, 24))}
|
|
tm := &TabManager{
|
|
rows: 24,
|
|
cols: 100,
|
|
tabs: []*Tab{first, second},
|
|
activeIdx: 0,
|
|
}
|
|
|
|
col := tabBarBrandWidth() + len(" ✅ 1:Codex ") + 1
|
|
if !tm.handleTabBarMouseEvent(mouseEvent{x: col, y: 24, button: 0, press: true}) {
|
|
t.Fatal("expected tab-bar click to be handled")
|
|
}
|
|
if tm.activeIdx != 1 {
|
|
t.Fatalf("activeIdx = %d, want 1", tm.activeIdx)
|
|
}
|
|
}
|
|
|
|
func TestHandleTabBarMouseEventResumesCommandMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
first := &Tab{label: "Codex", startedAt: time.Now().Add(-tabStartingWindow - time.Second), vt: vt10x.New(vt10x.WithSize(80, 24))}
|
|
second := &Tab{label: "Gemini", startedAt: time.Now().Add(-tabStartingWindow - time.Second), vt: vt10x.New(vt10x.WithSize(80, 24))}
|
|
tm := &TabManager{
|
|
rows: 24,
|
|
cols: 100,
|
|
tabs: []*Tab{first, second},
|
|
activeIdx: 0,
|
|
commandMode: true,
|
|
}
|
|
|
|
col := tabBarBrandWidth() + len(" ✅ 1:Codex ") + 1
|
|
if !tm.handleTabBarMouseEvent(mouseEvent{x: col, y: 24, button: 0, press: true}) {
|
|
t.Fatal("expected tab-bar click to be handled")
|
|
}
|
|
if tm.commandMode {
|
|
t.Fatal("expected command mode to exit after tab click")
|
|
}
|
|
if tm.activeIdx != 1 {
|
|
t.Fatalf("activeIdx = %d, want 1", tm.activeIdx)
|
|
}
|
|
}
|
|
|
|
func TestResolveRenderCursorAppDrawnCursorTabsHideHostCursorEvenWithSavedPosition(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, label := range []string{"Claude Code", "Gemini CLI"} {
|
|
tab := &Tab{label: label}
|
|
tab.cursorSavedX.Store(17)
|
|
tab.cursorSavedY.Store(6)
|
|
tab.cursorSavedValid.Store(true)
|
|
|
|
gotX, gotY, gotVisible := resolveRenderCursor(tab, 2, 19, false, false)
|
|
if gotX != 2 || gotY != 19 || gotVisible {
|
|
t.Fatalf("%s resolveRenderCursor() = (%d, %d, %t), want (2, 19, false)", label, gotX, gotY, gotVisible)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveRenderCursorAppDrawnCursorTabsHideHostCursorWithoutSavedPosition(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, label := range []string{"Claude Code", "Gemini CLI"} {
|
|
tab := &Tab{label: label}
|
|
|
|
gotX, gotY, gotVisible := resolveRenderCursor(tab, 9, 12, false, false)
|
|
if gotX != 9 || gotY != 12 || gotVisible {
|
|
t.Fatalf("%s resolveRenderCursor() = (%d, %d, %t), want (9, 12, false)", label, gotX, gotY, gotVisible)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveRenderCursorNonClaudeKeepsExistingFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := &Tab{label: "Codex"}
|
|
tab.cursorSavedX.Store(1)
|
|
tab.cursorSavedY.Store(0)
|
|
tab.cursorSavedValid.Store(true)
|
|
|
|
gotX, gotY, gotVisible := resolveRenderCursor(tab, 15, 8, false, true)
|
|
if gotX != 1 || gotY != 0 || !gotVisible {
|
|
t.Fatalf("resolveRenderCursor() = (%d, %d, %t), want (1, 0, true)", gotX, gotY, gotVisible)
|
|
}
|
|
}
|
|
|
|
func TestRenderVTScreenKeepsReverseDefaultSpaceVisible(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
term := vt10x.New(vt10x.WithSize(3, 1))
|
|
if _, err := term.Write([]byte("\x1b[7m \x1b[27m")); err != nil {
|
|
t.Fatalf("write reverse space: %v", err)
|
|
}
|
|
|
|
term.Lock()
|
|
got := renderVTScreen(term, 3, 1)
|
|
term.Unlock()
|
|
|
|
if !strings.Contains(got, "\x1b[0;7m ") {
|
|
t.Fatalf("renderVTScreen() = %q, want reverse-video space to remain visible", got)
|
|
}
|
|
}
|
|
|
|
func TestIsTerminalResponseRecognizesDCSAndCSIReplies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
seq []byte
|
|
}{
|
|
{
|
|
name: "dcs xtversion",
|
|
seq: []byte("\x1bP>|iTerm2 3.6.664\x1b\\"),
|
|
},
|
|
{
|
|
name: "csi device attrs",
|
|
seq: []byte("\x1b[?1;2;4;6;17;18;21;22;52c"),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !isTerminalResponse(tc.seq) {
|
|
t.Fatalf("expected %q to be recognized as a terminal response", tc.seq)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitizeSGR(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "no colons passthrough",
|
|
input: "\x1b[38;2;100;150;200m",
|
|
want: "\x1b[38;2;100;150;200m",
|
|
},
|
|
{
|
|
name: "curly underline rewritten to basic",
|
|
input: "\x1b[4:3m",
|
|
want: "\x1b[4m",
|
|
},
|
|
{
|
|
name: "colon fg true-color with colorspace",
|
|
input: "\x1b[38:2:0:100:150:200m",
|
|
want: "\x1b[38;2;100;150;200m",
|
|
},
|
|
{
|
|
name: "colon fg true-color without colorspace",
|
|
input: "\x1b[38:2:100:150:200m",
|
|
want: "\x1b[38;2;100;150;200m",
|
|
},
|
|
{
|
|
name: "colon bg 256-color",
|
|
input: "\x1b[48:5:208m",
|
|
want: "\x1b[48;5;208m",
|
|
},
|
|
{
|
|
name: "underline color dropped",
|
|
input: "\x1b[58:2:0:255:0:0m",
|
|
want: "\x1b[m",
|
|
},
|
|
{
|
|
name: "mixed colon and semicolon params",
|
|
input: "\x1b[4:3;38;2;100;150;200m",
|
|
want: "\x1b[4;38;2;100;150;200m",
|
|
},
|
|
{
|
|
name: "plain text unmodified",
|
|
input: "hello world",
|
|
want: "hello world",
|
|
},
|
|
{
|
|
name: "non-SGR CSI with colon unmodified",
|
|
input: "\x1b[?2026h",
|
|
want: "\x1b[?2026h",
|
|
},
|
|
{
|
|
name: "colon fg true-color with empty colorspace",
|
|
input: "\x1b[38:2::100:150:200m",
|
|
want: "\x1b[38;2;100;150;200m",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := string(sanitizeSGR([]byte(tc.input)))
|
|
if got != tc.want {
|
|
t.Fatalf("sanitizeSGR(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContainsStandaloneBEL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("standalone bell detected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var state belParserState
|
|
if !containsStandaloneBEL([]byte("hello\x07world"), &state) {
|
|
t.Fatal("expected standalone BEL to be detected")
|
|
}
|
|
})
|
|
|
|
t.Run("osc terminator bell ignored", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var state belParserState
|
|
if containsStandaloneBEL([]byte("\x1b]0;window title\x07"), &state) {
|
|
t.Fatal("did not expect OSC terminator BEL to be treated as a notification")
|
|
}
|
|
})
|
|
|
|
t.Run("split osc terminator bell ignored", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var state belParserState
|
|
if containsStandaloneBEL([]byte("\x1b]0;window"), &state) {
|
|
t.Fatal("did not expect partial OSC chunk to contain a notification")
|
|
}
|
|
if containsStandaloneBEL([]byte(" title\x07"), &state) {
|
|
t.Fatal("did not expect BEL ending a split OSC sequence to be treated as a notification")
|
|
}
|
|
})
|
|
|
|
t.Run("osc st then bell still detects real alert", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var state belParserState
|
|
if !containsStandaloneBEL([]byte("\x1b]0;title\x1b\\\x07"), &state) {
|
|
t.Fatal("expected standalone BEL after OSC ST terminator to be detected")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestTabStatusEmoji(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
newTab := func() *Tab {
|
|
return &Tab{
|
|
label: "Codex",
|
|
startedAt: time.Now().Add(-tabStartingWindow - time.Second),
|
|
vt: vt10x.New(vt10x.WithSize(80, 24)),
|
|
}
|
|
}
|
|
|
|
t.Run("new tabs show startup hourglass", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := &Tab{
|
|
label: "Codex",
|
|
startedAt: time.Now(),
|
|
bell: true,
|
|
vt: vt10x.New(vt10x.WithSize(80, 24)),
|
|
}
|
|
if got := tabStatusEmoji(tab); got != "⏳" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want %q", got, "⏳")
|
|
}
|
|
})
|
|
|
|
t.Run("notification wins", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := newTab()
|
|
tab.bell = true
|
|
if got := tabStatusEmoji(tab); got != "🔔" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want %q", got, "🔔")
|
|
}
|
|
})
|
|
|
|
t.Run("consistent screen changes show progress", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := newTab()
|
|
now := time.Now()
|
|
tab.prevScreenChange = now.Add(-time.Second)
|
|
tab.lastScreenChange = now.Add(-500 * time.Millisecond)
|
|
if got := tabStatusEmoji(tab); got != "🧠" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want %q", got, "🧠")
|
|
}
|
|
})
|
|
|
|
t.Run("recent typing suppresses progress emoji", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := newTab()
|
|
now := time.Now()
|
|
tab.prevScreenChange = now.Add(-time.Second)
|
|
tab.lastScreenChange = now.Add(-500 * time.Millisecond)
|
|
noteTabUserInput(tab, now.Add(-250*time.Millisecond))
|
|
if got := tabStatusEmoji(tab); got != "✅" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want %q", got, "✅")
|
|
}
|
|
})
|
|
|
|
t.Run("single screen change stays waiting", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := newTab()
|
|
tab.lastScreenChange = time.Now()
|
|
if got := tabStatusEmoji(tab); got != "✅" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want %q", got, "✅")
|
|
}
|
|
})
|
|
|
|
t.Run("stale screen changes stay waiting", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := newTab()
|
|
now := time.Now()
|
|
tab.prevScreenChange = now.Add(-4 * time.Second)
|
|
tab.lastScreenChange = now.Add(-2 * time.Second)
|
|
if got := tabStatusEmoji(tab); got != "✅" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want %q", got, "✅")
|
|
}
|
|
})
|
|
|
|
t.Run("exited tab shows no status", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := newTab()
|
|
tab.exited.Store(true)
|
|
if got := tabStatusEmoji(tab); got != "" {
|
|
t.Fatalf("tabStatusEmoji() = %q, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNoteTabScreenChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tab := &Tab{
|
|
vt: vt10x.New(vt10x.WithSize(20, 4)),
|
|
}
|
|
now := time.Now()
|
|
|
|
if !noteTabScreenChange(tab, now) {
|
|
t.Fatal("expected initial screen snapshot to count as a change")
|
|
}
|
|
if noteTabScreenChange(tab, now.Add(100*time.Millisecond)) {
|
|
t.Fatal("did not expect identical screen snapshot to count as a change")
|
|
}
|
|
|
|
_, _ = tab.vt.Write([]byte("hello"))
|
|
if !noteTabScreenChange(tab, now.Add(200*time.Millisecond)) {
|
|
t.Fatal("expected changed VT screen to count as a change")
|
|
}
|
|
}
|