From 25a0f85b2d186d135d2492f4bee234c2620b66e8 Mon Sep 17 00:00:00 2001 From: pionxe Date: Wed, 22 Apr 2026 22:53:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/app.go | 30 +-- internal/tui/core/app/keymap.go | 40 ++-- internal/tui/core/app/keymap_test.go | 13 ++ internal/tui/core/app/styles.go | 72 ++++++- internal/tui/core/app/update.go | 112 +++++++++++ internal/tui/core/app/update_test.go | 115 +++++++++++ internal/tui/core/app/view.go | 281 +++++++++++++++++++++++++++ internal/tui/core/app/view_test.go | 128 +++++++++++- 8 files changed, 757 insertions(+), 34 deletions(-) diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 3af5c4b5..cde3c3fa 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -146,9 +146,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 { @@ -335,12 +340,15 @@ func newApp(container tuibootstrap.Container) (App, error) { markdownRenderer: markdownRenderer, }, appRuntimeState: appRuntimeState{ - nowFn: time.Now, - focus: panelInput, - todoFilter: todoFilterAll, - layoutCached: true, - cachedWidth: 128, - cachedHeight: 40, + nowFn: time.Now, + focus: panelInput, + todoFilter: todoFilterAll, + layoutCached: true, + cachedWidth: 128, + cachedHeight: 40, + startupVisible: true, + startupCursorOn: true, + startupPulsePhase: 0, }, width: 128, height: 40, @@ -375,9 +383,7 @@ func (a App) Init() tea.Cmd { ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick, - tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { - return tickMsg(t) - }), + startupAnimationTickCmd(), } if cmd := runModelCatalogRefresh(a.providerSvc, a.modelRefreshID); cmd != nil { cmds = append(cmds, cmd) 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 0b983520..40f45e7a 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 ( @@ -32,6 +33,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 { @@ -79,9 +90,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(). @@ -92,7 +122,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(). @@ -200,6 +230,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 caddc057..fc7c768c 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) @@ -83,6 +89,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.layoutCached = false a.applyComponentLayout(true) return a, tea.Batch(cmds...) + case tickMsg: + a.advanceStartupAnimation() + if a.startupVisible { + cmds = append(cmds, startupAnimationTickCmd()) + } + return a, tea.Batch(cmds...) case providerAddResultMsg: a.handleProviderAddResultMsg(typed) return a, nil @@ -283,6 +295,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 } @@ -342,6 +359,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 { @@ -659,6 +680,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 } @@ -2659,6 +2770,7 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot { } func (a *App) startDraftSession() { + a.dismissStartup() a.setActiveSessionID("") a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 19caf9e8..d2109873 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 a43903a1..45881868 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" @@ -22,6 +23,32 @@ type layout struct { const headerBarHeight = 2 const transcriptScrollbarWidth = 3 +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 @@ -42,6 +69,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 { @@ -63,6 +94,256 @@ func (a App) View() string { return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), "\n") } +// renderStartupView 负责组合启动页的 Header、Hero、Input、Footer 四段视图。 +func (a App) renderStartupView(width int, height int) string { + width = max(1, width) + height = max(1, height) + + headerRaw := a.renderStartupHeader(width) + inputRaw := a.renderStartupInput(width) + footerRaw := a.renderStartupFooter(width) + header := a.startupPaintBlock(width, headerRaw) + input := a.startupPaintBlock(width, inputRaw) + footer := "" + footerHeight := 0 + if strings.TrimSpace(footerRaw) != "" { + footer = a.startupPaintBlock(width, footerRaw) + footerHeight = lipgloss.Height(footer) + } + heroHeight := max(1, height-lipgloss.Height(header)-lipgloss.Height(input)-footerHeight) + hero := a.renderStartupHeroArea(width, heroHeight) + parts := []string{header, hero, input} + if footer != "" { + parts = append(parts, footer) + } + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + return a.styles.startupRoot.Copy().Width(width).Height(height).Render(content) +} + +// renderStartupHeader 渲染启动页顶部状态信息,保持品牌、模型和工作目录三段信息布局。 +func (a App) renderStartupHeader(width int) string { + model := tuiutils.Fallback(strings.TrimSpace(a.state.CurrentModel), "unknown-model") + workdir := tuiutils.Fallback(strings.TrimSpace(a.state.CurrentWorkdir), "-") + left := lipgloss.JoinHorizontal( + lipgloss.Left, + a.styles.startupBrand.Render("NeoCode"), + a.styles.startupSeparator.Render(" / "), + a.styles.startupHeaderMeta.Render(model), + a.styles.startupSeparator.Render(" / "), + a.styles.startupHeaderMeta.Render(startupStandbyLabel), + ) + + minGap := 2 + availableRight := width - lipgloss.Width(left) - minGap + if availableRight <= 0 { + return left + } + right := a.styles.startupHeaderMeta.Render( + tuiutils.TrimMiddle("cwd: "+workdir, max(12, availableRight)), + ) + return left + a.startupBlackSpaces(minGap) + right +} + +// renderStartupHeroLines 构建启动页中心主视觉的文本行与统一对齐锚点宽度。 +func (a App) renderStartupHeroLines() ([]string, int) { + logoColor := a.startupLogoColor() + logo := a.styles.startupLogo.Copy().Foreground(lipgloss.Color(logoColor)).Render(startupLogoASCII) + logoLines := strings.Split(logo, "\n") + subtitle := a.styles.startupSubtitle.Render(strings.ToUpper(startupSubtitleText)) + menuLines := a.renderStartupMenuLines() + + anchorWidth := max(48, lipgloss.Width(subtitle)) + for _, line := range logoLines { + anchorWidth = max(anchorWidth, lipgloss.Width(line)) + } + for _, line := range menuLines { + anchorWidth = max(anchorWidth, lipgloss.Width(line)) + } + + lines := make([]string, 0, len(logoLines)+len(menuLines)+2) + for _, line := range logoLines { + lines = append(lines, a.startupCenterWithinAnchor(anchorWidth, line)) + } + lines = append(lines, "") + lines = append(lines, a.startupCenterWithinAnchor(anchorWidth, subtitle)) + lines = append(lines, "") + for _, line := range menuLines { + lines = append(lines, a.startupCenterWithinAnchor(anchorWidth, line)) + } + return lines, anchorWidth +} + +// renderStartupHeroArea 将 Hero 区块按垂直居中排布到固定高度,并把每一行补位刷成纯黑。 +func (a App) renderStartupHeroArea(width int, height int) string { + if height <= 0 { + return "" + } + heroLines, anchorWidth := a.renderStartupHeroLines() + contentHeight := len(heroLines) + topPadding := max(0, (height-contentHeight)/2) + bottomPadding := max(0, height-topPadding-contentHeight) + + lines := make([]string, 0, height) + for i := 0; i < topPadding; i++ { + lines = append(lines, a.startupBlackLine(width, "")) + } + for _, line := range heroLines { + lines = append(lines, a.startupBlackLine(width, a.startupCenterLine(width, line, anchorWidth))) + } + for i := 0; i < bottomPadding; i++ { + lines = append(lines, a.startupBlackLine(width, "")) + } + return strings.Join(lines, "\n") +} + +// renderStartupMenuLines 渲染启动页快捷操作列表行,按键胶囊与动作说明分离展示。 +func (a App) renderStartupMenuLines() []string { + if len(startupMenuItems) == 0 { + return nil + } + + maxKeyWidth := 0 + for _, item := range startupMenuItems { + maxKeyWidth = max(maxKeyWidth, lipgloss.Width(item.Key)) + } + keyCapWidth := maxKeyWidth + a.styles.startupKeyCap.GetHorizontalFrameSize() + + rows := make([]string, 0, len(startupMenuItems)) + maxRowWidth := 0 + for _, item := range startupMenuItems { + keyCap := a.styles.startupKeyCap. + Copy(). + Width(keyCapWidth). + Align(lipgloss.Center). + Render(item.Key) + action := a.styles.startupMenuAction.Render(item.Action) + row := keyCap + a.startupBlackSpaces(2) + action + rows = append(rows, row) + maxRowWidth = max(maxRowWidth, lipgloss.Width(row)) + } + + for i := range rows { + rowWidth := lipgloss.Width(rows[i]) + if rowWidth < maxRowWidth { + rows[i] += a.startupBlackSpaces(maxRowWidth - rowWidth) + } + } + return rows +} + +// startupLogoColor 根据当前呼吸 phase 选择 Logo 颜色,模拟启动页呼吸灯效果。 +func (a App) startupLogoColor() string { + if startupBreathCycleTicks <= 0 { + return startupLogoBaseColor + } + intensity := (math.Sin(a.startupPulsePhase-math.Pi/2) + 1) / 2 + + r := startupBlendChannel(0x7a, 0xf0, intensity) + g := startupBlendChannel(0x80, 0xf2, intensity) + b := startupBlendChannel(0x88, 0xf4, intensity) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +func startupBlendChannel(minValue int, maxValue int, t float64) int { + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + return minValue + int(math.Round(float64(maxValue-minValue)*t)) +} + +// renderStartupInput 渲染启动页底部输入区,包含弱分割线、打字机文本和闪烁光标。 +func (a App) renderStartupInput(width int) string { + line := strings.Repeat("─", max(1, width)) + divider := a.styles.startupDivider.Render(line) + cursor := a.startupBlackSpaces(1) + if a.startupCursorOn { + cursor = a.styles.startupCursor.Render("█") + } + prompt := a.styles.startupPrompt.Render("❯") + typing := a.styles.startupTyping.Render(a.startupTypingText()) + inputLine := prompt + a.startupBlackSpaces(2) + typing + cursor + return lipgloss.JoinVertical(lipgloss.Left, divider, inputLine) +} + +// startupTypingText 根据打字机索引返回当前应显示的占位文本切片。 +func (a App) startupTypingText() string { + chars := []rune(startupTypingPlaceholder) + if len(chars) == 0 { + return "" + } + index := tuiutils.Clamp(a.startupTypingIndex, 0, len(chars)) + return string(chars[:index]) +} + +// renderStartupFooter 预留启动页底部区域;当前按设计不显示额外提示,避免与 Logo 下方说明重复。 +func (a App) renderStartupFooter(width int) string { + _ = width + return "" +} + +// startupPaintBlock 将多行文本补齐到固定宽度,避免上一帧遗留字符污染当前画面。 +func (a App) startupPaintBlock(width int, block string) string { + lines := strings.Split(block, "\n") + for i, line := range lines { + lines[i] = a.startupBlackLine(width, line) + } + return strings.Join(lines, "\n") +} + +// startupBlackLine 渲染固定宽度行,必要时在行尾追加空格补齐。 +func (a App) startupBlackLine(width int, text string) string { + if width <= 0 { + return "" + } + visibleWidth := lipgloss.Width(text) + if visibleWidth > width { + text = ansi.Cut(text, 0, width) + visibleWidth = width + } + if visibleWidth < width { + text += a.startupBlackSpaces(width - visibleWidth) + } + return text +} + +// startupCenterLine 在不填充右侧空白文本的前提下进行居中偏移。 +func (a App) startupCenterLine(width int, text string, anchorWidth int) string { + if width <= 0 { + return text + } + if anchorWidth <= 0 { + anchorWidth = lipgloss.Width(text) + } + leftPad := max(0, (width-anchorWidth)/2) + return a.startupBlackSpaces(leftPad) + text +} + +// startupCenterWithinAnchor 在 anchor 宽度内将文本做局部居中。 +func (a App) startupCenterWithinAnchor(anchorWidth int, text string) string { + if anchorWidth <= 0 { + return text + } + textWidth := lipgloss.Width(text) + if textWidth >= anchorWidth { + return text + } + totalPad := anchorWidth - textWidth + leftPad := totalPad / 2 + rightPad := totalPad - leftPad + return a.startupBlackSpaces(leftPad) + text + a.startupBlackSpaces(rightPad) +} + +// startupBlackSpaces 返回用于布局补位的空格串,不附带背景色以避免透明终端中的补丁感。 +func (a App) startupBlackSpaces(count int) string { + if count <= 0 { + return "" + } + return strings.Repeat(" ", count) +} + func (a App) renderHeader(width int) string { status := compactStatusText(a.state.StatusText, max(18, width/3)) if a.state.IsAgentRunning { diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 580a038f..3c790023 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 @@ -457,13 +577,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) } @@ -475,13 +595,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) }