diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index eb78a761..5acb9162 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -137,9 +137,6 @@ type appRuntimeState struct { transcriptContent string transcriptScrollbarDrag bool startupScreenLocked bool - startupIntroActive bool - startupIntroFrame int - startupLoopFrame int textSelection struct { active bool @@ -344,7 +341,6 @@ func newApp(container tuibootstrap.Container) (App, error) { cachedHeight: 40, // 初始进入草稿态时锁定启动页,直到发送或切换 session 才退出。 startupScreenLocked: true, - startupIntroActive: false, }, width: 128, height: 40, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 472feb3e..622a3b5f 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -66,6 +66,13 @@ var lookupProviderUserEnvVar = config.LookupUserEnvVar func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd + batchUpdateCmds := func() tea.Cmd { + if a.deferredFooterTick != nil { + cmds = append(cmds, a.deferredFooterTick) + a.deferredFooterTick = nil + } + return tea.Batch(cmds...) + } a.syncFooterErrorToast() a.spinner, spinCmd = a.spinner.Update(msg) if a.isBusy() { @@ -86,7 +93,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.height = typed.Height a.layoutCached = false a.applyComponentLayout(true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case tickMsg: now := time.Time(typed) needNextTick := false @@ -97,15 +104,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if needNextTick { cmds = append(cmds, appTickCmd()) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case providerAddResultMsg: a.handleProviderAddResultMsg(typed) - return a, nil + return a, batchUpdateCmds() case RuntimeMsg: runtimeEvent, ok := typed.Event.(tuiservices.RuntimeEvent) if !ok { cmds = append(cmds, ListenForRuntimeEvent(a.runtime.Events())) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } transcriptDirty := a.handleRuntimeEvent(runtimeEvent) if a.deferredEventCmd != nil { @@ -117,13 +124,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.rebuildTranscript() } cmds = append(cmds, ListenForRuntimeEvent(a.runtime.Events())) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case logPersistFlushMsg: if typed.Version != a.logPersistVersion || !a.logPersistDirty { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.persistLogEntriesForActiveSession() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case RuntimeClosedMsg: a.state.IsAgentRunning = false a.state.StreamingReply = false @@ -135,7 +142,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.TrimSpace(a.state.StatusText) == "" { a.state.StatusText = statusRuntimeClosed } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case runFinishedMsg: if typed.Err != nil { a.state.IsAgentRunning = false @@ -157,7 +164,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.syncActiveSessionTitle() a.syncTodosFromRun() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case permissionResolutionFinishedMsg: if a.pendingPermission != nil && a.pendingPermission.Request.RequestID == typed.RequestID { if typed.Err != nil { @@ -173,24 +180,24 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.refreshPermissionPromptLayout() } } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case modelCatalogRefreshMsg: if strings.EqualFold(a.modelRefreshID, typed.ProviderID) { a.modelRefreshID = "" } if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(typed.ProviderID)) { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if typed.Err != nil { a.appendActivity("provider", "Failed to refresh models", typed.Err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } replacePickerItems(&a.modelPicker, mapModelItems(typed.Models)) cfg := a.configManager.Get() a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case compactFinishedMsg: a.state.IsCompacting = false if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { @@ -205,7 +212,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncActiveSessionTitle() a.rebuildTranscript() a.transcript.GotoBottom() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case localCommandResultMsg: if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -221,13 +228,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("system", "Failed to refresh providers", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if err := a.refreshModelPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("system", "Failed to refresh models", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.selectCurrentProvider(cfg.SelectedProvider) a.selectCurrentModel(cfg.CurrentModel) @@ -239,13 +246,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.appendActivity("command", typed.Notice, "", false) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case skillCommandResultMsg: requestSessionID := strings.TrimSpace(typed.RequestSessionID) activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) if requestSessionID != "" && !strings.EqualFold(requestSessionID, activeSessionID) { a.recordStaleSkillCommandResult(requestSessionID, activeSessionID, typed.Err) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -262,13 +269,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.appendActivity("skills", "Skill command completed", notice, false) } a.rebuildTranscript() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case workspaceCommandResultMsg: if typed.Command == "" && typed.Err != nil { a.state.ExecutionError = typed.Err.Error() a.state.StatusText = typed.Err.Error() a.appendActivity("command", "Workspace command failed", typed.Err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } result := formatWorkspaceCommandResult(typed.Command, typed.Output, typed.Err) if typed.Err != nil { @@ -280,22 +287,22 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StatusText = statusCommandDone a.appendActivity("command", "Command finished", result, false) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case tea.MouseMsg: if a.logViewerVisible && a.handleLogViewerMouse(typed) { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.handleTranscriptMouse(typed) { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.handleActivityMouse(typed) { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.handleTodoMouse(typed) { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.handleInputMouse(typed) { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } case tea.KeyMsg: if key.Matches(typed, a.keys.Quit) { @@ -305,20 +312,20 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.ShowHelp = !a.state.ShowHelp a.help.ShowAll = a.state.ShowHelp a.applyComponentLayout(true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.state.IsAgentRunning && key.Matches(typed, a.keys.CancelAgent) { if a.runtime.CancelActiveRun() { a.state.StatusText = statusCanceling } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } if a.logViewerVisible { if handled := a.handleLogViewerKey(typed); handled { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } } @@ -327,12 +334,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } } if a.focus == panelInput && key.Matches(typed, a.keys.NextPanel) { if a.applySelectedCommandSuggestion() { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if a.shouldHandleTabAsInput(typed) { tabMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}, Paste: typed.Paste} @@ -341,25 +348,25 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if key.Matches(typed, a.keys.NextPanel) { a.focusNext() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if key.Matches(typed, a.keys.PrevPanel) { a.focusPrev() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if key.Matches(typed, a.keys.FocusInput) { a.clearTextSelection() a.focus = panelInput a.applyFocus() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if key.Matches(typed, a.keys.NewSession) && !a.isBusy() { a.startDraftSession() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if key.Matches(typed, a.keys.OpenWorkspace) { a.openFileBrowser() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if key.Matches(typed, a.keys.PasteImage) { @@ -367,7 +374,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StatusText = err.Error() a.appendActivity("multimodal", "Failed to paste image", err.Error(), true) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if key.Matches(typed, a.keys.LogViewer) { @@ -377,16 +384,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.logViewerPrevStatus = strings.TrimSpace(a.state.StatusText) a.state.StatusText = "Log viewer" a.applyComponentLayout(false) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } switch a.focus { case panelTranscript: a.handleViewportKeys(&a.transcript, typed) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case panelActivity: a.handleViewportKeys(&a.activity, typed) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case panelTodo: switch { case key.Matches(typed, a.keys.ScrollUp): @@ -423,25 +430,32 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.applyComponentLayout(false) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case panelInput: return a.updateInputPanel(msg, typed, cmds) } } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { now := a.now() effectiveTyped := typed + batchUpdateCmds := func() tea.Cmd { + if a.deferredFooterTick != nil { + cmds = append(cmds, a.deferredFooterTick) + a.deferredFooterTick = nil + } + return tea.Batch(cmds...) + } if a.pendingPermission != nil { if cmd, handled := a.updatePendingPermissionInput(typed); handled { if cmd != nil { cmds = append(cmds, cmd) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } } @@ -454,7 +468,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te input := strings.TrimSpace(a.input.Value()) hasImages := a.hasImageAttachments() if (input == "" && !hasImages) || a.isBusy() { - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if handled, cmd := a.handleImmediateSlashCommand(input); handled { @@ -466,7 +480,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if cmd != nil { cmds = append(cmds, cmd) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if isImageReferenceInput(input) { @@ -480,7 +494,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.applyComponentLayout(true) a.refreshCommandMenu() a.resetPasteHeuristics() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.input.Reset() @@ -492,52 +506,52 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te case slashCommandHelp: a.refreshHelpPicker() a.openHelpPicker() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("system", "Failed to refresh providers", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.openProviderPicker() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case slashCommandModelPick: if err := a.refreshModelPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("system", "Failed to refresh models", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.openModelPicker() if cmd := a.requestModelCatalogRefresh(a.state.CurrentProvider); cmd != nil { cmds = append(cmds, cmd) } - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case slashCommandSession: if err := a.ensureSessionSwitchAllowed(""); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("session", "Failed to open session picker", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if err := a.refreshSessionPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("system", "Failed to refresh sessions", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.openPicker(pickerSession, statusChooseSession, &a.sessionPicker, a.state.ActiveSessionID) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() case slashCommandProviderAdd: a.startProviderAddForm() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if strings.HasPrefix(input, slashPrefix) { a.state.StatusText = statusApplyingCommand cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), input)) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if isWorkspaceCommandInput(input) { command, err := extractWorkspaceCommand(input) @@ -545,14 +559,14 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("command", "Invalid workspace command", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } a.clearActivities() a.state.StatusText = statusRunningCommand a.state.ExecutionError = "" a.appendActivity("command", "Running command", command, false) cmds = append(cmds, runWorkspaceCommand(a.configManager, a.state.CurrentWorkdir, input)) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } normalizedInput, absorbedImages, err := a.absorbInlineImageReferences(input) @@ -560,7 +574,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("multimodal", "Failed to absorb inline image reference", err.Error(), true) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } if absorbedImages > 0 { input = normalizedInput @@ -602,7 +616,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te Images: images, })) a.clearImageAttachments() - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } } @@ -621,7 +635,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.applyComponentLayout(false) a.refreshCommandMenu() cmds = append(cmds, cmd) - return a, tea.Batch(cmds...) + return a, batchUpdateCmds() } // updatePendingPermissionInput handles keyboard interaction in the permission prompt. @@ -2683,9 +2697,6 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot { func (a *App) startDraftSession() { a.setActiveSessionID("") a.startupScreenLocked = false - a.startupIntroActive = false - a.startupIntroFrame = 0 - a.startupLoopFrame = 0 a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil a.clearActivities() @@ -2772,13 +2783,7 @@ func runCompact(runtime tuiservices.Runtime, sessionID string) tea.Cmd { func (a *App) setActiveSessionID(sessionID string) { next := strings.TrimSpace(sessionID) current := strings.TrimSpace(a.state.ActiveSessionID) - if next == "" { - a.startupIntroActive = false - a.startupIntroFrame = 0 - if current != "" { - a.startupLoopFrame = 0 - } - } else { + if next != "" { a.startupScreenLocked = false } if strings.EqualFold(current, next) { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 57f865f8..e1a9b16a 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1341,27 +1341,12 @@ func TestNewWithBootstrapMissingDependencies(t *testing.T) { } } -func TestStartupScreenIsStaticByDefault(t *testing.T) { - app, _ := newTestApp(t) - if app.startupIntroActive { - t.Fatalf("expected startup intro animation to be disabled by default") - } -} - func TestStartupScreenTickDoesNotAnimateLogo(t *testing.T) { app, _ := newTestApp(t) app.startupScreenLocked = true - app.startupIntroFrame = 7 - app.startupLoopFrame = 11 model, cmd := app.Update(tickMsg(time.Unix(1_700_000_000, 0))) app = model.(App) - if app.startupIntroFrame != 7 { - t.Fatalf("expected static startup intro frame, got %d", app.startupIntroFrame) - } - if app.startupLoopFrame != 11 { - t.Fatalf("expected static startup loop frame, got %d", app.startupLoopFrame) - } if cmd != nil { t.Fatalf("expected no follow-up tick when only startup screen is visible") }