diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index d5b7d8c8..c0f5990a 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -150,9 +150,14 @@ type appRuntimeState struct { endCol int } - footerErrorLast string - footerErrorText string - footerErrorUntil time.Time + footerErrorLast string + footerErrorText string + footerErrorUntil time.Time + startupVisible bool + startupTick int + startupTypingIndex int + startupCursorOn bool + startupPulsePhase float64 } type pendingImageAttachment struct { diff --git a/internal/tui/core/app/keymap.go b/internal/tui/core/app/keymap.go index bdbb6658..3c87697f 100644 --- a/internal/tui/core/app/keymap.go +++ b/internal/tui/core/app/keymap.go @@ -3,23 +3,24 @@ package tui import "github.com/charmbracelet/bubbles/key" type keyMap struct { - Send key.Binding - Newline key.Binding - CancelAgent key.Binding - NewSession key.Binding - NextPanel key.Binding - PrevPanel key.Binding - FocusInput key.Binding - ToggleHelp key.Binding - Quit key.Binding - ScrollUp key.Binding - ScrollDown key.Binding - PageUp key.Binding - PageDown key.Binding - Top key.Binding - Bottom key.Binding - PasteImage key.Binding - LogViewer key.Binding + Send key.Binding + Newline key.Binding + CancelAgent key.Binding + NewSession key.Binding + OpenWorkspace key.Binding + NextPanel key.Binding + PrevPanel key.Binding + FocusInput key.Binding + ToggleHelp key.Binding + Quit key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + PageUp key.Binding + PageDown key.Binding + Top key.Binding + Bottom key.Binding + PasteImage key.Binding + LogViewer key.Binding } func newKeyMap() keyMap { @@ -40,6 +41,10 @@ func newKeyMap() keyMap { key.WithKeys("ctrl+n"), key.WithHelp("Ctrl+N", "New session"), ), + OpenWorkspace: key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("Ctrl+O", "Workspace"), + ), NextPanel: key.NewBinding( key.WithKeys("tab"), key.WithHelp("Tab", "Next panel"), @@ -102,6 +107,7 @@ func (k keyMap) ShortHelp() []key.Binding { func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Send, k.Newline, k.CancelAgent, k.NewSession}, + {k.OpenWorkspace}, {k.FocusInput, k.NextPanel, k.PrevPanel}, {k.ToggleHelp, k.Quit, k.PasteImage, k.ScrollUp}, {k.PageUp, k.PageDown, k.Top, k.Bottom}, diff --git a/internal/tui/core/app/keymap_test.go b/internal/tui/core/app/keymap_test.go index 0f62320e..ea524911 100644 --- a/internal/tui/core/app/keymap_test.go +++ b/internal/tui/core/app/keymap_test.go @@ -30,3 +30,16 @@ func TestFullHelpIncludesLogViewer(t *testing.T) { t.Fatalf("expected full help to include log viewer binding") } } + +func TestFullHelpIncludesOpenWorkspace(t *testing.T) { + keys := newKeyMap() + help := keys.FullHelp() + for _, row := range help { + for _, binding := range row { + if binding.Help().Key == keys.OpenWorkspace.Help().Key { + return + } + } + } + t.Fatalf("expected full help to include open workspace binding") +} diff --git a/internal/tui/core/app/styles.go b/internal/tui/core/app/styles.go index 65e6a704..f26af9a9 100644 --- a/internal/tui/core/app/styles.go +++ b/internal/tui/core/app/styles.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" ) const ( @@ -36,6 +37,16 @@ const ( borderDark = "#3d3654" borderLight = "#4a4268" + + startupBackgroundColor = "#000000" + startupLogoBaseColor = "#f3f4f6" + startupMetaColor = "#6b7280" + startupHeaderColor = "#4b5563" + startupMenuActionColor = "#9ca3af" + startupKeyCapBGColor = "#1a1a1a" + startupDividerColor = "#1f2937" + startupPromptColor = "#bd93f9" + startupFooterColor = "#4b5563" ) type styles struct { @@ -83,9 +94,28 @@ type styles struct { badgeWarning lipgloss.Style badgeError lipgloss.Style badgeMuted lipgloss.Style + startupRoot lipgloss.Style + startupHeader lipgloss.Style + startupBrand lipgloss.Style + startupHeaderMeta lipgloss.Style + startupSeparator lipgloss.Style + startupLogo lipgloss.Style + startupSubtitle lipgloss.Style + startupMenu lipgloss.Style + startupMenuItem lipgloss.Style + startupKeyCap lipgloss.Style + startupMenuAction lipgloss.Style + startupInput lipgloss.Style + startupDivider lipgloss.Style + startupPrompt lipgloss.Style + startupTyping lipgloss.Style + startupCursor lipgloss.Style + startupFooter lipgloss.Style } func newStyles() styles { + lipgloss.SetColorProfile(termenv.TrueColor) + headerAccent := lipgloss.AdaptiveColor{Light: coralAccent, Dark: purpleLight} panel := lipgloss.NewStyle(). @@ -96,7 +126,7 @@ func newStyles() styles { return styles{ doc: lipgloss.NewStyle(). Padding(1, 2, 0, 2). - UnsetBackground(), + Background(lipgloss.Color(startupBackgroundColor)), headerBar: lipgloss.NewStyle(). UnsetBackground(), headerBrand: lipgloss.NewStyle(). @@ -206,6 +236,46 @@ func newStyles() styles { badgeWarning: badge("", warningYellow), badgeError: badge("", errorRed), badgeMuted: badge("", stoneGray), + startupRoot: lipgloss.NewStyle(). + UnsetBackground(), + startupHeader: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupHeaderColor)), + startupBrand: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupHeaderColor)). + Bold(true), + startupHeaderMeta: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupHeaderColor)), + startupSeparator: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupHeaderColor)), + startupLogo: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupLogoBaseColor)). + Bold(true), + startupSubtitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupMetaColor)). + Align(lipgloss.Center), + startupMenu: lipgloss.NewStyle(), + startupMenuItem: lipgloss.NewStyle(), + startupKeyCap: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color(startupKeyCapBGColor)). + Padding(0, 1), + startupMenuAction: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupMenuActionColor)), + startupInput: lipgloss.NewStyle(), + startupDivider: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupDividerColor)), + startupPrompt: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupPromptColor)). + Bold(true), + startupTyping: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupMetaColor)), + startupCursor: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#ffffff")). + Reverse(true), + startupFooter: lipgloss.NewStyle(). + Foreground(lipgloss.Color(startupFooterColor)). + Align(lipgloss.Center), } } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 9f8f6995..50b0572f 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" "path/filepath" "strings" "time" @@ -51,6 +52,11 @@ const sessionSwitchBusyMessage = "cannot switch sessions while run or compact is const logViewerEntryLimit = 500 const logViewerPersistDebounce = 300 * time.Millisecond const footerErrorFlashDuration = 8 * time.Second +const startupAnimationTickInterval = 180 * time.Millisecond +const startupTypingStartDelayTicks = 6 +const startupTypingStepTicks = 1 +const startupCursorBlinkStepTicks = 3 +const startupPulseStepTicks = 1 type sessionLogPersistenceRuntime interface { LoadSessionLogEntries(ctx context.Context, sessionID string) ([]tuiservices.SessionLogEntry, error) @@ -294,6 +300,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } case tea.KeyMsg: + if a.startupVisible { + if model, cmd, handled := a.handleStartupKey(typed, cmds); handled { + return model, cmd + } + } if key.Matches(typed, a.keys.Quit) { return a, tea.Quit } @@ -353,6 +364,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.startDraftSession() return a, tea.Batch(cmds...) } + if key.Matches(typed, a.keys.OpenWorkspace) { + a.openFileBrowser() + return a, tea.Batch(cmds...) + } if key.Matches(typed, a.keys.PasteImage) { if err := a.addImageFromClipboard(); err != nil { @@ -671,6 +686,96 @@ func (a App) now() time.Time { return a.nowFn() } +// startupAnimationTickCmd 发送启动页动画节拍消息,用于驱动呼吸灯与打字机效果。 +func startupAnimationTickCmd() tea.Cmd { + return tea.Tick(startupAnimationTickInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// advanceStartupAnimation 推进启动页动效状态,包括呼吸 phase、打字索引与光标闪烁。 +func (a *App) advanceStartupAnimation() { + if !a.startupVisible { + return + } + a.startupTick++ + + if startupPulseStepTicks > 0 && a.startupTick%startupPulseStepTicks == 0 { + if startupBreathCycleTicks > 0 { + step := 2 * math.Pi / float64(startupBreathCycleTicks) + a.startupPulsePhase += step + if a.startupPulsePhase >= 2*math.Pi { + a.startupPulsePhase -= 2 * math.Pi + } + } + } + if startupCursorBlinkStepTicks > 0 && a.startupTick%startupCursorBlinkStepTicks == 0 { + a.startupCursorOn = !a.startupCursorOn + } + if a.startupTick < startupTypingStartDelayTicks { + return + } + if startupTypingStepTicks > 0 && a.startupTick%startupTypingStepTicks != 0 { + return + } + maxChars := len([]rune(startupTypingPlaceholder)) + if a.startupTypingIndex < maxChars { + a.startupTypingIndex++ + } +} + +// dismissStartup 隐藏启动页并恢复输入焦点,确保后续按键进入主流程处理。 +func (a *App) dismissStartup() { + if !a.startupVisible { + return + } + a.startupVisible = false + a.focus = panelInput + a.applyFocus() + a.applyComponentLayout(false) +} + +// handleStartupKey 处理启动页专属按键网关,必要时切换到主线输入流程。 +func (a App) handleStartupKey(typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd, bool) { + switch { + case key.Matches(typed, a.keys.NewSession): + a.dismissStartup() + a.startDraftSession() + return a, tea.Batch(cmds...), true + case key.Matches(typed, a.keys.OpenWorkspace): + a.dismissStartup() + a.openFileBrowser() + return a, tea.Batch(cmds...), true + case typed.Type == tea.KeyRunes && len(typed.Runes) == 1 && typed.Runes[0] == '/': + a.dismissStartup() + a.input.SetValue("/") + a.state.InputText = a.input.Value() + a.refreshCommandMenu() + a.applyComponentLayout(false) + return a, tea.Batch(cmds...), true + case key.Matches(typed, a.keys.FocusInput): + return a, tea.Quit, true + case key.Matches(typed, a.keys.Quit): + return a, tea.Quit, true + case isStartupRegularInput(typed): + a.dismissStartup() + model, cmd := a.updateInputPanel(typed, typed, cmds) + return model, cmd, true + default: + return a, tea.Batch(cmds...), true + } +} + +// isStartupRegularInput 判断按键是否属于可直接落入输入框的常规字符输入。 +func isStartupRegularInput(msg tea.KeyMsg) bool { + switch msg.Type { + case tea.KeyRunes, tea.KeySpace: + return true + default: + return false + } +} + type logPersistFlushMsg struct { Version int } @@ -2671,6 +2776,7 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot { } func (a *App) startDraftSession() { + a.dismissStartup() a.setActiveSessionID("") a.startupScreenLocked = true a.startupIntroActive = false diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 4e581b3e..aa6d700b 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -367,12 +367,127 @@ func newTestApp(t *testing.T) (App, *stubRuntime) { } app, runtime := newTestAppWithProviderService(t, stubProviderService{providers: providers, models: models}) + app.startupVisible = false app.layoutCached = true app.cachedWidth = app.width app.cachedHeight = app.height return app, runtime } +func TestStartupKeyEscQuits(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + next := model.(App) + if !next.startupVisible { + t.Fatalf("expected startup to stay visible before quit command is consumed") + } + if cmd == nil { + t.Fatalf("expected quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg from quit command") + } +} + +func TestStartupSlashTransitionsToComposer(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + next := model.(App) + if next.startupVisible { + t.Fatalf("expected startup to be dismissed") + } + if got := next.input.Value(); got != "/" { + t.Fatalf("expected composer input '/', got %q", got) + } + if got := next.state.InputText; got != "/" { + t.Fatalf("expected state input '/', got %q", got) + } + if len(next.commandMenu.Items()) == 0 { + t.Fatalf("expected slash suggestions to be visible") + } +} + +func TestStartupRegularInputDismissesStartup(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + next := model.(App) + if next.startupVisible { + t.Fatalf("expected startup to be dismissed") + } + if got := next.input.Value(); got != "h" { + t.Fatalf("expected composer input to receive typed rune, got %q", got) + } +} + +func TestStartupCtrlONavigatesToWorkspaceBrowser(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + next := model.(App) + if next.startupVisible { + t.Fatalf("expected startup to be dismissed") + } + if next.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker active, got %v", next.state.ActivePicker) + } +} + +func TestStartupCtrlNStartsDraftSession(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + app.state.ActiveSessionID = "session-1" + app.state.ActiveSessionTitle = "Old Session" + app.input.SetValue("old content") + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + next := model.(App) + if next.startupVisible { + t.Fatalf("expected startup to be dismissed") + } + if next.state.ActiveSessionID != "" { + t.Fatalf("expected draft session id to be empty") + } + if next.state.ActiveSessionTitle != draftSessionTitle { + t.Fatalf("expected draft session title, got %q", next.state.ActiveSessionTitle) + } + if got := next.input.Value(); got != "" { + t.Fatalf("expected composer reset, got %q", got) + } +} + +func TestStartupTickAdvancesTypingAndPulse(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + app.startupTypingIndex = 0 + app.startupPulsePhase = 0 + + var model tea.Model = app + for i := 0; i < startupTypingStartDelayTicks; i++ { + next, cmd := model.(App).Update(tickMsg(time.Now())) + model = next + if cmd == nil { + t.Fatalf("expected recurring startup tick command at step %d", i) + } + } + final := model.(App) + if final.startupTick == 0 { + t.Fatalf("expected startup tick to advance") + } + if final.startupTypingIndex == 0 { + t.Fatalf("expected startup typing index to advance") + } + if final.startupPulsePhase == 0 { + t.Fatalf("expected startup pulse phase to advance") + } +} + func TestSubmitProviderAddFormRequiresCustomDriverBaseURL(t *testing.T) { app, _ := newTestApp(t) app.startProviderAddForm() diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index f4b0c754..f59bcefe 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "math" "strings" "github.com/charmbracelet/lipgloss" @@ -23,6 +24,32 @@ const headerBarHeight = 2 const transcriptScrollbarWidth = 3 const startupCommandMenuMinReservedHeight = 8 +const startupStandbyLabel = "Standby" +const startupSubtitleText = "AI-Powered CLI Workspace" +const startupTypingPlaceholder = "Ask NeoCode to inspect, edit, or build..." +const startupBreathCycleTicks = 45 + +const startupLogoASCII = `███╗ ██╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗ +████╗ ██║██╔════╝██╔═══██╗██╔════╝██╔═══██╗██╔══██╗██╔════╝ +██╔██╗ ██║█████╗ ██║ ██║██║ ██║ ██║██║ ██║█████╗ +██║╚██╗██║██╔══╝ ██║ ██║██║ ██║ ██║██║ ██║██╔══╝ +██║ ╚████║███████╗╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗ +╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝` + +type startupMenuItem struct { + Key string + Action string +} + +var startupMenuItems = []startupMenuItem{ + {Key: "Enter", Action: "Send the Prompt to LLM"}, + {Key: "Ctrl+J", Action: "Insert a New Line"}, + {Key: "Ctrl+W", Action: "Cancel Current Run"}, + {Key: "Ctrl+L", Action: "Open Log Viewer"}, + {Key: "Ctrl+Q", Action: "Toggle Help Panel"}, + {Key: "Ctrl+U", Action: "Exit NeoCode"}, +} + const ( pickerPanelHorizontalInset = 8 pickerPanelVerticalInset = 4 @@ -43,6 +70,10 @@ type pickerLayoutSpec struct { } func (a App) View() string { + if a.startupVisible { + return strings.TrimRight(a.renderStartupView(max(0, a.width), max(0, a.height)), "\n") + } + docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) if docWidth < 60 || docHeight < 20 { diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index f62d073b..207b2b20 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" @@ -36,6 +37,125 @@ func TestRenderPickerHelpMode(t *testing.T) { } } +func TestViewStartupVisibleRendersStartupSections(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + app.width = 120 + app.height = 36 + app.startupTypingIndex = len([]rune(startupTypingPlaceholder)) + + view := app.View() + plain := copyCodeANSIPattern.ReplaceAllString(view, "") + if !strings.Contains(plain, strings.ToUpper(startupSubtitleText)) { + t.Fatalf("expected startup subtitle in view") + } + if !strings.Contains(plain, "Send the Prompt to LLM") { + t.Fatalf("expected startup action description in view") + } + if strings.Contains(plain, "Ctrl+N") { + t.Fatalf("expected legacy startup action to be removed") + } +} + +func TestStartupTypingTextClamp(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + app.startupTypingIndex = 999 + + got := app.startupTypingText() + if got != startupTypingPlaceholder { + t.Fatalf("expected full placeholder text, got %q", got) + } +} + +func TestStartupBlackLinePadsToRequestedWidth(t *testing.T) { + app, _ := newTestApp(t) + + line := app.startupBlackLine(20, app.styles.startupHeaderMeta.Render("cwd")) + if got := ansi.StringWidth(line); got != 20 { + t.Fatalf("expected rendered width 20, got %d", got) + } +} + +func TestStartupCenterWithinAnchorKeepsAnchorWidth(t *testing.T) { + app, _ := newTestApp(t) + + centered := app.startupCenterWithinAnchor(40, app.styles.startupSubtitle.Render(strings.ToUpper(startupSubtitleText))) + if got := ansi.StringWidth(centered); got != 40 { + t.Fatalf("expected centered line width 40, got %d", got) + } +} + +func TestStartupInputLineDoesNotContainKeyCapBackgroundCode(t *testing.T) { + app, _ := newTestApp(t) + app.startupVisible = true + app.width = 120 + app.height = 36 + app.startupTypingIndex = len([]rune(startupTypingPlaceholder)) + + view := app.View() + lines := strings.Split(view, "\n") + inputLine := "" + for _, line := range lines { + if strings.Contains(line, startupTypingPlaceholder) { + inputLine = line + break + } + } + if inputLine == "" { + t.Fatalf("expected startup input line in rendered view") + } + if strings.Contains(inputLine, "48;2;26;26;26m") { + t.Fatalf("expected input line not to inherit keycap background code") + } +} + +func TestStartupMenuLinesAlignedAsCenteredBlock(t *testing.T) { + app, _ := newTestApp(t) + + lines := app.renderStartupMenuLines() + if len(lines) != len(startupMenuItems) { + t.Fatalf("expected %d startup menu lines, got %d", len(startupMenuItems), len(lines)) + } + + expectedWidth := ansi.StringWidth(lines[0]) + actionColumn := -1 + for i, line := range lines { + if got := ansi.StringWidth(line); got != expectedWidth { + t.Fatalf("expected line %d width %d, got %d", i, expectedWidth, got) + } + + plain := copyCodeANSIPattern.ReplaceAllString(line, "") + action := startupMenuItems[i].Action + idx := strings.Index(plain, action) + if idx < 0 { + t.Fatalf("expected action %q in menu line %q", action, plain) + } + if actionColumn == -1 { + actionColumn = idx + continue + } + if idx != actionColumn { + t.Fatalf("expected action column %d, got %d for line %d", actionColumn, idx, i) + } + } +} + +func TestStartupMenuLinesDoNotWrapKeyCaps(t *testing.T) { + app, _ := newTestApp(t) + + lines := app.renderStartupMenuLines() + for i, line := range lines { + if strings.Contains(line, "\n") { + t.Fatalf("expected startup menu line %d to stay single-line, got wrapped content", i) + } + plain := copyCodeANSIPattern.ReplaceAllString(line, "") + if !strings.Contains(plain, startupMenuItems[i].Key) { + t.Fatalf("expected menu line %d to contain key %q, got %q", i, startupMenuItems[i].Key, plain) + } + } +} + func TestRenderPickerSessionMode(t *testing.T) { app, _ := newTestApp(t) app.state.ActivePicker = pickerSession @@ -501,13 +621,13 @@ func TestRenderPanelAndActivityPreview(t *testing.T) { func TestRenderHelpShowsCtrlLAndError(t *testing.T) { app, _ := newTestApp(t) app.state.StatusText = statusReady - rendered := app.renderHelp(80) + rendered := copyCodeANSIPattern.ReplaceAllString(app.renderHelp(80), "") if !strings.Contains(rendered, "Ctrl+L Log viewer") { t.Fatalf("expected footer help to include log viewer shortcut, got %q", rendered) } app.showFooterError("permission denied") - rendered = app.renderHelp(80) + rendered = copyCodeANSIPattern.ReplaceAllString(app.renderHelp(80), "") if !strings.Contains(rendered, "Error: permission denied") { t.Fatalf("expected footer to surface execution error, got %q", rendered) } @@ -519,13 +639,13 @@ func TestRenderHelpErrorToastExpires(t *testing.T) { app.nowFn = func() time.Time { return base } app.showFooterError("permission denied") - rendered := app.renderHelp(80) + rendered := copyCodeANSIPattern.ReplaceAllString(app.renderHelp(80), "") if !strings.Contains(rendered, "Error: permission denied") { t.Fatalf("expected footer toast to show immediately, got %q", rendered) } app.nowFn = func() time.Time { return base.Add(footerErrorFlashDuration + 50*time.Millisecond) } - rendered = app.renderHelp(80) + rendered = copyCodeANSIPattern.ReplaceAllString(app.renderHelp(80), "") if strings.Contains(rendered, "Error: permission denied") { t.Fatalf("expected footer toast to auto-hide after flash duration, got %q", rendered) }