diff --git a/internal/runtime/session_logs.go b/internal/runtime/session_logs.go new file mode 100644 index 00000000..49a32ffb --- /dev/null +++ b/internal/runtime/session_logs.go @@ -0,0 +1,116 @@ +package runtime + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const logViewerPersistDir = "log-viewer" + +// SessionLogEntry 描述会话维度的日志查看器持久化条目。 +type SessionLogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Source string `json:"source"` + Message string `json:"message"` +} + +// LoadSessionLogEntries 按会话 ID 读取日志查看器持久化数据。 +func (s *Service) LoadSessionLogEntries(ctx context.Context, sessionID string) ([]SessionLogEntry, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + path, err := s.sessionLogEntriesPath(sessionID) + if err != nil || path == "" { + return nil, err + } + payload, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("runtime: read session log entries: %w", err) + } + entries := make([]SessionLogEntry, 0) + if err := json.Unmarshal(payload, &entries); err != nil { + return nil, fmt.Errorf("runtime: decode session log entries: %w", err) + } + return append([]SessionLogEntry(nil), entries...), nil +} + +// SaveSessionLogEntries 将日志查看器条目写入会话维度持久化存储。 +func (s *Service) SaveSessionLogEntries(ctx context.Context, sessionID string, entries []SessionLogEntry) error { + if err := ctx.Err(); err != nil { + return err + } + path, err := s.sessionLogEntriesPath(sessionID) + if err != nil || path == "" { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("runtime: ensure session log directory: %w", err) + } + payload, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("runtime: encode session log entries: %w", err) + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + return fmt.Errorf("runtime: write session log entries: %w", err) + } + return nil +} + +// sessionLogEntriesPath 生成会话日志文件路径,并确保命名稳定且避免会话 ID 冲突。 +func (s *Service) sessionLogEntriesPath(sessionID string) (string, error) { + normalizedSessionID := strings.TrimSpace(sessionID) + if normalizedSessionID == "" { + return "", nil + } + if s == nil || s.configManager == nil { + return "", errors.New("runtime: config manager is not initialized") + } + baseDir := strings.TrimSpace(s.configManager.BaseDir()) + if baseDir == "" { + return "", errors.New("runtime: config base directory is empty") + } + sum := sha256.Sum256([]byte(normalizedSessionID)) + fileName := fmt.Sprintf("%s_%s.json", sanitizeSessionLogPrefix(normalizedSessionID), hex.EncodeToString(sum[:8])) + return filepath.Join(baseDir, logViewerPersistDir, fileName), nil +} + +// sanitizeSessionLogPrefix 生成可读前缀,便于排查文件,同时不参与唯一性判定。 +func sanitizeSessionLogPrefix(sessionID string) string { + var b strings.Builder + for _, r := range sessionID { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '_' || r == '-': + b.WriteRune(r) + default: + if b.Len() > 0 { + b.WriteByte('_') + } + } + if b.Len() >= 24 { + break + } + } + prefix := strings.Trim(b.String(), "_") + if prefix == "" { + return "session" + } + return prefix +} diff --git a/internal/runtime/session_logs_test.go b/internal/runtime/session_logs_test.go new file mode 100644 index 00000000..9feca663 --- /dev/null +++ b/internal/runtime/session_logs_test.go @@ -0,0 +1,114 @@ +package runtime + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "neo-code/internal/config" +) + +func newSessionLogTestService(t *testing.T) *Service { + t.Helper() + cfg := config.StaticDefaults() + cfg.Workdir = t.TempDir() + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + return &Service{configManager: manager} +} + +func TestSessionLogEntriesPathAndSanitizePrefix(t *testing.T) { + service := newSessionLogTestService(t) + + pathA, err := service.sessionLogEntriesPath("a:b") + if err != nil || pathA == "" { + t.Fatalf("sessionLogEntriesPath(a:b) err=%v path=%q", err, pathA) + } + pathB, err := service.sessionLogEntriesPath("a/b") + if err != nil || pathB == "" { + t.Fatalf("sessionLogEntriesPath(a/b) err=%v path=%q", err, pathB) + } + if pathA == pathB { + t.Fatalf("expected different file names for potential sanitize collision ids, got %q", pathA) + } + if got := sanitizeSessionLogPrefix(" /a:b?c* "); got == "" { + t.Fatal("expected sanitizeSessionLogPrefix to produce fallback prefix") + } + if got := sanitizeSessionLogPrefix("___"); got != "session" { + t.Fatalf("sanitizeSessionLogPrefix(___)=%q, want session", got) + } +} + +func TestLoadAndSaveSessionLogEntries(t *testing.T) { + service := newSessionLogTestService(t) + sessionID := "session-one" + source := []SessionLogEntry{ + {Timestamp: time.Unix(1700000000, 0), Level: "info", Source: "tool", Message: "ok"}, + } + + if err := service.SaveSessionLogEntries(context.Background(), sessionID, source); err != nil { + t.Fatalf("SaveSessionLogEntries() error = %v", err) + } + loaded, err := service.LoadSessionLogEntries(context.Background(), sessionID) + if err != nil { + t.Fatalf("LoadSessionLogEntries() error = %v", err) + } + if len(loaded) != 1 || loaded[0].Message != "ok" { + t.Fatalf("unexpected loaded entries: %+v", loaded) + } + + missing, err := service.LoadSessionLogEntries(context.Background(), "missing-session") + if err != nil { + t.Fatalf("LoadSessionLogEntries(missing) error = %v", err) + } + if len(missing) != 0 { + t.Fatalf("expected missing session to return empty entries, got %+v", missing) + } +} + +func TestSessionLogEntriesErrorBranches(t *testing.T) { + service := newSessionLogTestService(t) + + if err := service.SaveSessionLogEntries(context.Background(), "", nil); err != nil { + t.Fatalf("SaveSessionLogEntries(blank) should skip, got err=%v", err) + } + if _, err := service.LoadSessionLogEntries(context.Background(), ""); err != nil { + t.Fatalf("LoadSessionLogEntries(blank) should skip, got err=%v", err) + } + + path, err := service.sessionLogEntriesPath("bad-json") + if err != nil { + t.Fatalf("sessionLogEntriesPath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if _, err := service.LoadSessionLogEntries(context.Background(), "bad-json"); err == nil { + t.Fatal("expected invalid json load error") + } + + brokenService := &Service{configManager: nil} + if err := brokenService.SaveSessionLogEntries(context.Background(), "id", nil); err == nil { + t.Fatal("expected save error when config manager is nil") + } + if _, err := brokenService.LoadSessionLogEntries(context.Background(), "id"); err == nil { + t.Fatal("expected load error when config manager is nil") + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if err := service.SaveSessionLogEntries(cancelled, "id", nil); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled error on save, got %v", err) + } + if _, err := service.LoadSessionLogEntries(cancelled, "id"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled error on load, got %v", err) + } +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 8d9a1109..334af827 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -23,6 +23,13 @@ import ( tuistate "neo-code/internal/tui/state" ) +type logEntry struct { + Timestamp time.Time + Level string + Source string + Message string +} + type panel = tuistate.Panel const ( @@ -95,6 +102,7 @@ type appRuntimeState struct { codeCopyBlocks map[int]string pendingCopyID int deferredEventCmd tea.Cmd + deferredLogPersistCmd tea.Cmd nowFn func() time.Time lastInputEditAt time.Time lastPasteLikeAt time.Time @@ -122,6 +130,17 @@ type appRuntimeState struct { cachedWidth int cachedHeight int viewDirty bool + logViewerVisible bool + logViewerOffset int + logViewerPrevStatus string + logEntries []logEntry + logPersistDirty bool + logPersistVersion int + transcriptContent string + transcriptScrollbarDrag bool + footerErrorLast string + footerErrorText string + footerErrorUntil time.Time } type pendingImageAttachment struct { diff --git a/internal/tui/core/app/keymap.go b/internal/tui/core/app/keymap.go index 3d604fb3..bdbb6658 100644 --- a/internal/tui/core/app/keymap.go +++ b/internal/tui/core/app/keymap.go @@ -19,6 +19,7 @@ type keyMap struct { Top key.Binding Bottom key.Binding PasteImage key.Binding + LogViewer key.Binding } func newKeyMap() keyMap { @@ -87,11 +88,15 @@ func newKeyMap() keyMap { key.WithKeys("ctrl+v"), key.WithHelp("Ctrl+V", "Paste image"), ), + LogViewer: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("Ctrl+L", "Log viewer"), + ), } } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Send, k.Newline, k.CancelAgent, k.ToggleHelp, k.Quit} + return []key.Binding{k.Send, k.Newline, k.CancelAgent, k.LogViewer, k.ToggleHelp, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { @@ -100,5 +105,6 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.FocusInput, k.NextPanel, k.PrevPanel}, {k.ToggleHelp, k.Quit, k.PasteImage, k.ScrollUp}, {k.PageUp, k.PageDown, k.Top, k.Bottom}, + {k.LogViewer}, } } diff --git a/internal/tui/core/app/keymap_test.go b/internal/tui/core/app/keymap_test.go index c08126cb..0f62320e 100644 --- a/internal/tui/core/app/keymap_test.go +++ b/internal/tui/core/app/keymap_test.go @@ -14,3 +14,19 @@ func TestFullHelpIncludesPasteImage(t *testing.T) { } t.Fatalf("expected full help to include paste image binding") } + +func TestFullHelpIncludesLogViewer(t *testing.T) { + keys := newKeyMap() + help := keys.FullHelp() + foundLogViewer := false + for _, row := range help { + for _, binding := range row { + if binding.Help().Key == keys.LogViewer.Help().Key { + foundLogViewer = true + } + } + } + if !foundLogViewer { + t.Fatalf("expected full help to include log viewer binding") + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index fc9c7c97..490ff4da 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -48,17 +48,33 @@ const providerAddNonPersistentEnvWarning = "API key is applied to the current pr const providerAddManualModelsJSONTemplate = "[\n {\n \"id\": \"model-id\",\n \"name\": \"Model Name\"\n }\n]" const sessionSwitchBusyMessage = "cannot switch sessions while run or compact is active" +const logViewerEntryLimit = 500 +const logViewerPersistDebounce = 300 * time.Millisecond +const footerErrorFlashDuration = 4 * time.Second -var panelOrder = []panel{panelTranscript, panelActivity, panelInput} +type sessionLogPersistenceRuntime interface { + LoadSessionLogEntries(ctx context.Context, sessionID string) ([]agentruntime.SessionLogEntry, error) + SaveSessionLogEntries(ctx context.Context, sessionID string, entries []agentruntime.SessionLogEntry) error +} + +var panelOrder = []panel{panelTranscript, panelInput} var supportsUserEnvPersistence = config.SupportsUserEnvPersistence +var persistProviderUserEnvVar = config.PersistUserEnvVar +var deleteProviderUserEnvVar = config.DeleteUserEnvVar +var lookupProviderUserEnvVar = config.LookupUserEnvVar func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd + a.syncFooterErrorToast() a.spinner, spinCmd = a.spinner.Update(msg) if a.isBusy() { cmds = append(cmds, spinCmd) } + if a.deferredLogPersistCmd != nil { + cmds = append(cmds, a.deferredLogPersistCmd) + a.deferredLogPersistCmd = nil + } switch typed := msg.(type) { case tea.WindowSizeMsg: @@ -82,6 +98,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, ListenForRuntimeEvent(a.runtime.Events())) return a, tea.Batch(cmds...) + case logPersistFlushMsg: + if typed.Version != a.logPersistVersion || !a.logPersistDirty { + return a, tea.Batch(cmds...) + } + a.persistLogEntriesForActiveSession() + return a, tea.Batch(cmds...) case RuntimeClosedMsg: a.state.IsAgentRunning = false a.state.StreamingReply = false @@ -217,6 +239,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) case tea.MouseMsg: + if a.logViewerVisible && a.handleLogViewerMouse(typed) { + return a, tea.Batch(cmds...) + } if a.handleTranscriptMouse(typed) { return a, tea.Batch(cmds...) } @@ -248,6 +273,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } + if a.logViewerVisible { + if handled := a.handleLogViewerKey(typed); handled { + return a, tea.Batch(cmds...) + } + } if a.focus == panelInput { if cmd, handled := a.updateCommandMenuSelection(typed); handled { @@ -292,6 +322,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } + if key.Matches(typed, a.keys.LogViewer) { + a.logViewerVisible = true + a.logViewerOffset = 0 + a.viewDirty = true + a.logViewerPrevStatus = strings.TrimSpace(a.state.StatusText) + a.state.StatusText = "Log viewer" + a.applyComponentLayout(false) + return a, tea.Batch(cmds...) + } + switch a.focus { case panelTranscript: a.handleViewportKeys(&a.transcript, typed) @@ -590,6 +630,17 @@ func (a App) now() time.Time { return a.nowFn() } +type logPersistFlushMsg struct { + Version int +} + +// scheduleLogPersistFlush 在短暂静默后触发日志落盘,避免每条活动都同步刷盘。 +func scheduleLogPersistFlush(version int) tea.Cmd { + return tea.Tick(logViewerPersistDebounce, func(time.Time) tea.Msg { + return logPersistFlushMsg{Version: version} + }) +} + func (a *App) shouldTreatEnterAsNewline(typed tea.KeyMsg, now time.Time) bool { if !key.Matches(typed, a.keys.Send) || a.state.IsAgentRunning { return false @@ -794,6 +845,7 @@ func (a *App) refreshMessages() error { a.activeMessages = nil a.clearActivities() a.clearTodos() + a.loadLogEntriesForSession("") return nil } @@ -807,6 +859,7 @@ func (a *App) refreshMessages() error { a.syncTodos(session.Todos) a.state.ActiveSessionTitle = session.Title a.setCurrentWorkdir(agentsession.EffectiveWorkdir(session.Workdir, a.configManager.Get().Workdir)) + a.loadLogEntriesForSession(session.ID) a.refreshRuntimeSourceSnapshot() return nil } @@ -865,7 +918,7 @@ func (a *App) activateSelectedSession() error { return err } - a.state.ActiveSessionID = item.Summary.ID + a.setActiveSessionID(item.Summary.ID) a.state.ActiveSessionTitle = item.Summary.Title a.state.ExecutionError = "" a.state.CurrentTool = "" @@ -879,7 +932,7 @@ func (a *App) activateSessionByID(sessionID string) error { } for _, s := range a.state.Sessions { if s.ID == sessionID { - a.state.ActiveSessionID = s.ID + a.setActiveSessionID(s.ID) a.state.ActiveSessionTitle = s.Title a.state.ExecutionError = "" a.state.CurrentTool = "" @@ -972,7 +1025,7 @@ func (a *App) refreshRuntimeSourceSnapshot() { } } -// runtimeSessionContextSource 缂備焦鎷濋梽鍕焽椤愶箑鐭楁い鏍亹閸嬫挻寰勭仦鍓ф殸婵炴潙鍚嬫穱娲儊閼恒儳鈻斿┑鐘辫兌閻熸捇鏌¢崒姘闁绘搫绱曢幏鐘诲閿濆懎骞嬮梺鍛婃⒐缁嬪繘鍩€ +// runtimeSessionContextSource 定义读取会话上下文快照的最小接口,便于在 UI 侧按需刷新运行态信息。 type runtimeSessionContextSource interface { GetSessionContext(ctx context.Context, sessionID string) (any, error) } @@ -1026,7 +1079,7 @@ func runtimeEventPhaseChangedHandler(a *App, event agentruntime.RuntimeEvent) bo return false } -// runtimeEventStopReasonDecidedHandler 婵犮垼娉涚€氼噣骞冩繝鍥ц埞妞ゆ牗鐟ч杈╃磽娴e摜澧涙い鎺撶⊕缁傚秶鈧綆浜為弶钘壝瑰鍐惧剮婵炲棎鍨芥俊 +// runtimeEventStopReasonDecidedHandler 在运行结束原因落地后统一收敛状态与提示信息。 func runtimeEventStopReasonDecidedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.StopReasonDecidedPayload) if !ok { @@ -1223,7 +1276,7 @@ func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) boo a.state.ActiveRunID = runID } if sessionID := strings.TrimSpace(event.SessionID); sessionID != "" { - a.state.ActiveSessionID = sessionID + a.setActiveSessionID(sessionID) } a.state.StatusText = statusThinking a.state.StreamingReply = false @@ -1259,7 +1312,7 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool mapped := tuiservices.MapRunContextPayload(event.RunID, event.SessionID, payload) a.state.RunContext = mapped if strings.TrimSpace(mapped.SessionID) != "" { - a.state.ActiveSessionID = strings.TrimSpace(mapped.SessionID) + a.setActiveSessionID(mapped.SessionID) } if strings.TrimSpace(mapped.RunID) != "" { a.state.ActiveRunID = mapped.RunID @@ -1303,7 +1356,7 @@ func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { return false } -// runtimeEventToolCallThinkingHandler 婵犮垼娉涚€氼噣骞冩繝鍌ゅ晠闁靛鍎卞鏃堟偡濞嗗繐顏╅柛銊︾箞濮婂ジ鎳滃▓鍨杸婵炲瓨绮岄鍕枎閵忋倕违 +// runtimeEventToolCallThinkingHandler 在工具调用进入思考阶段时同步当前工具与进度提示。 func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent) bool { if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.CurrentTool = payload @@ -1313,7 +1366,7 @@ func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent return false } -// runtimeEventToolStartHandler 婵犮垼娉涚€氼噣骞冩繝鍌ゅ晠闁靛鍎卞鏃傗偓娈垮枓閸嬫挸鈹戦纰卞剱濠⒀呮櫕閹壆浠﹂懖鈺冩婵炲濮剧紙浼村焵 +// runtimeEventToolStartHandler 在工具实际执行时更新状态条和活动记录。 func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StatusText = statusRunningTool a.state.StreamingReply = false @@ -1349,7 +1402,7 @@ func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool return true } -// runtimeEventAgentChunkHandler 婵犮垼娉涚€氼噣骞冩繝鍋界喖鍨惧畷鍥e亾瀹勬噴瑙勬媴閸濄儳顢呮繝鈷€鍛槐闁革絿鍎ゅ蹇涘箻閸愬弶鐦旈梺 +// runtimeEventAgentChunkHandler 将流式回复分片持续追加到转录区,并推进运行进度。 func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(string) if !ok { @@ -1370,7 +1423,7 @@ func runtimeEventToolChunkHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventAgentDoneHandler 婵犮垼娉涚€氼噣骞冩繝鍐╀氦闁归偊鍨奸弨浠嬫倵閻熺増婀伴柛銊︾缁傚秶鈧絺鏅滈浠嬫煏 +// runtimeEventAgentDoneHandler 在代理回复结束时收尾状态并补齐最终 assistant 消息。 func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.IsAgentRunning = false a.state.StreamingReply = false @@ -1404,7 +1457,7 @@ func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) boo return false } -// runtimeEventErrorHandler 婵犮垼娉涚€氼噣骞冩繝鍐╀氦闁归偊鍨奸弨浠嬫煛閸愩劎鍩i柡浣革功閹风娀顢涘▎鎴犳婵炲濮剧紙浼村焵 +// runtimeEventErrorHandler 在运行报错时统一清理现场并展示错误信息。 func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StatusText = statusError a.state.IsAgentRunning = false @@ -1489,7 +1542,7 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve return false } -// refreshPermissionPromptLayout 闂侀潻璐熼崝宀€绮╂搴濇勃闁逞屽墮椤斿繘骞撻幒鎴犱淮婵犳鍠栭鍛偓鍨叀瀵喚鎹勯崫鍕幈闂佸搫鍊绘晶妤€顭囬崼銉︹挃闁规壆澧楀銊╂煛婢跺孩纭舵繛鏉戭樀瀹曟鎼归銏㈢懇闂佺粯顨呴悧鍕焵 +// refreshPermissionPromptLayout 在权限提示出现或消失后刷新布局,避免遮挡输入区。 func (a *App) refreshPermissionPromptLayout() { if a.width <= 0 || a.height <= 0 { return @@ -1576,8 +1629,37 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b if len(a.activities) > maxActivityEntries { a.activities = a.activities[len(a.activities)-maxActivityEntries:] } + if isError { + a.showFooterError(fallbackText(detail, title)) + } a.syncActivityViewport(previousCount) a.viewDirty = true + a.addLogEntry(kind, title, detail) +} + +func (a *App) syncFooterErrorToast() { + current := strings.TrimSpace(a.state.ExecutionError) + if current == "" { + a.footerErrorLast = "" + return + } + if strings.EqualFold(current, a.footerErrorLast) { + return + } + a.footerErrorLast = current + a.showFooterError(current) +} + +func (a *App) showFooterError(message string) { + message = strings.TrimSpace(message) + if message == "" { + return + } + if !strings.HasPrefix(strings.ToLower(message), "error:") { + message = "Error: " + message + } + a.footerErrorText = message + a.footerErrorUntil = a.now().Add(footerErrorFlashDuration) } func (a *App) clearActivities() { @@ -1589,6 +1671,35 @@ func (a *App) clearActivities() { a.syncActivityViewport(previousCount) } +func (a *App) addLogEntry(kind string, title string, detail string) { + level := "info" + if strings.Contains(title, "error") || strings.Contains(title, "Error") || strings.Contains(title, "failed") { + level = "error" + } else if strings.Contains(title, "warn") || strings.Contains(title, "Warn") { + level = "warn" + } + + a.logEntries = append(a.logEntries, logEntry{ + Timestamp: time.Now(), + Level: level, + Source: kind, + Message: title + ": " + detail, + }) + + a.logEntries = clampLogEntries(a.logEntries) + _, _, _, height := a.logViewerBounds() + maxOffset := a.logViewerMaxOffset(height) + if a.logViewerOffset > maxOffset { + a.logViewerOffset = maxOffset + } + if strings.TrimSpace(a.state.ActiveSessionID) == "" { + return + } + a.logPersistDirty = true + a.logPersistVersion++ + a.deferredLogPersistCmd = scheduleLogPersistFlush(a.logPersistVersion) +} + func (a *App) syncActivityViewport(previousCount int) { visibleBefore := previousCount > 0 visibleNow := len(a.activities) > 0 @@ -1624,7 +1735,91 @@ func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { } } +func (a *App) handleLogViewerKey(msg tea.KeyMsg) bool { + _, _, _, height := a.logViewerBounds() + rows := a.logViewerRows(height) + + switch { + case key.Matches(msg, a.keys.LogViewer), key.Matches(msg, a.keys.FocusInput): + a.logViewerVisible = false + a.restoreStatusAfterLogViewer() + a.applyComponentLayout(false) + a.viewDirty = true + case key.Matches(msg, a.keys.ScrollUp): + a.scrollLogViewer(-1, height) + case key.Matches(msg, a.keys.ScrollDown): + a.scrollLogViewer(1, height) + case key.Matches(msg, a.keys.PageUp): + a.scrollLogViewer(-rows, height) + case key.Matches(msg, a.keys.PageDown): + a.scrollLogViewer(rows, height) + case key.Matches(msg, a.keys.Top): + if a.logViewerOffset != 0 { + a.logViewerOffset = 0 + a.viewDirty = true + } + case key.Matches(msg, a.keys.Bottom): + maxOffset := a.logViewerMaxOffset(height) + if a.logViewerOffset != maxOffset { + a.logViewerOffset = maxOffset + a.viewDirty = true + } + } + return true +} + +func (a *App) handleLogViewerMouse(msg tea.MouseMsg) bool { + if !a.isMouseWithinLogViewer(msg) { + return true + } + + _, _, _, height := a.logViewerBounds() + switch { + case msg.Button == tea.MouseButtonWheelUp && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelUp): + a.scrollLogViewer(-1, height) + case msg.Button == tea.MouseButtonWheelDown && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelDown): + a.scrollLogViewer(1, height) + } + return true +} + +func (a *App) scrollLogViewer(delta int, height int) { + if delta == 0 { + return + } + next := a.logViewerOffset + delta + if next < 0 { + next = 0 + } + maxOffset := a.logViewerMaxOffset(height) + if next > maxOffset { + next = maxOffset + } + if next != a.logViewerOffset { + a.logViewerOffset = next + a.viewDirty = true + } +} + func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { + if a.transcriptScrollbarDrag { + switch { + case msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion: + a.setTranscriptOffsetFromScrollbarY(msg.Y) + return true + case msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease: + a.transcriptScrollbarDrag = false + a.setTranscriptOffsetFromScrollbarY(msg.Y) + return true + } + } + + if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress && a.isMouseWithinTranscriptScrollbar(msg) { + a.transcriptScrollbarDrag = true + a.setTranscriptOffsetFromScrollbarY(msg.Y) + return true + } + switch { case msg.Button == tea.MouseButtonWheelUp && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelUp): if !a.isMouseWithinTranscript(msg) { @@ -1643,13 +1838,12 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { if !a.isMouseWithinTranscript(msg) { if msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease { a.pendingCopyID = 0 + a.transcriptScrollbarDrag = false } return false } switch { - case msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion: - return false case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: if buttonID, ok := a.copyButtonIDAtMouse(msg); ok { a.pendingCopyID = buttonID @@ -1682,8 +1876,38 @@ func (a App) isMouseWithinTranscript(msg tea.MouseMsg) bool { return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height } -func (a App) transcriptBounds() (int, int, int, int) { +func (a App) isMouseWithinTranscriptScrollbar(msg tea.MouseMsg) bool { + x, y, width, height := a.transcriptScrollbarBounds() + if width <= 0 || height <= 0 { + return false + } + return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height +} + +func (a App) isMouseWithinLogViewer(msg tea.MouseMsg) bool { + x, y, width, height := a.logViewerBounds() + if width <= 0 || height <= 0 { + return false + } + return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height +} + +func (a App) logViewerBounds() (int, int, int, int) { lay := a.computeLayout() + contentX := a.styles.doc.GetPaddingLeft() + contentY := a.styles.doc.GetPaddingTop() + return contentX, contentY + headerBarHeight, lay.contentWidth, lay.contentHeight +} + +func (a App) logViewerRows(height int) int { + return max(1, height-5) +} + +func (a App) logViewerMaxOffset(height int) int { + return max(0, len(a.logEntries)-a.logViewerRows(height)) +} + +func (a App) transcriptBounds() (int, int, int, int) { contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() headerHeight := headerBarHeight @@ -1692,7 +1916,16 @@ func (a App) transcriptBounds() (int, int, int, int) { streamX := contentX streamY := bodyY - return streamX, streamY, lay.contentWidth, a.transcript.Height + return streamX, streamY, a.transcript.Width, a.transcript.Height +} + +func (a App) transcriptScrollbarBounds() (int, int, int, int) { + lay := a.computeLayout() + contentX := a.styles.doc.GetPaddingLeft() + contentY := a.styles.doc.GetPaddingTop() + bodyY := contentY + headerBarHeight + scrollbarWidth := max(0, lay.contentWidth-a.transcript.Width) + return contentX + a.transcript.Width, bodyY, scrollbarWidth, a.transcript.Height } func (a App) isMouseWithinInput(msg tea.MouseMsg) bool { @@ -1997,27 +2230,16 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { prevTodoWidth := a.todo.Width prevTodoHeight := a.todo.Height a.help.ShowAll = a.state.ShowHelp - a.transcript.Width = lay.contentWidth + a.transcript.Width = max(1, lay.contentWidth-a.transcriptScrollbarWidth(lay.contentWidth)) a.resizeCommandMenu() a.input.SetWidth(a.composerInnerWidth(lay.contentWidth)) a.input.SetHeight(a.composerHeight()) - transcriptHeight, activityHeight, _, todoHeight := a.waterfallMetrics(a.transcript.Width, lay.contentHeight) + transcriptHeight, activityHeight, _, todoHeight := a.waterfallMetrics(lay.contentWidth, lay.contentHeight) a.transcript.Height = transcriptHeight - if activityHeight > 0 { - panelStyle := a.styles.panelFocused - frameHeight := panelStyle.GetVerticalFrameSize() - borderWidth := 2 - paddingWidth := panelStyle.GetHorizontalFrameSize() - borderWidth - panelWidth := max(1, lay.contentWidth-borderWidth) - bodyWidth := max(10, panelWidth-paddingWidth) - bodyHeight := max(1, activityHeight-frameHeight-1) - a.activity.Width = bodyWidth - a.activity.Height = bodyHeight - } else { - a.activity.Width = max(10, lay.contentWidth-4) - a.activity.Height = 0 - } + _ = activityHeight + a.activity.Width = max(10, lay.contentWidth-4) + a.activity.Height = 0 if todoHeight > 0 { panelStyle := a.styles.panelFocused @@ -2046,7 +2268,7 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.fileBrowser.SetHeight(max(pickerListMinHeight, pickerLayout.listHeight)) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() - } else if a.transcript.AtBottom() || a.isBusy() { + } else if a.transcript.AtBottom() { a.transcript.GotoBottom() } if prevActivityWidth != a.activity.Width || prevActivityHeight != a.activity.Height { @@ -2087,7 +2309,7 @@ func (a *App) rebuildTranscript() { width := max(24, a.transcript.Width) if len(a.activeMessages) == 0 { a.setCodeCopyBlocks(nil) - a.transcript.SetContent(a.styles.empty.Width(width).Render(emptyConversationText)) + a.setTranscriptContent(a.styles.empty.Width(width).Render(emptyConversationText)) a.transcript.GotoTop() return } @@ -2110,12 +2332,22 @@ func (a *App) rebuildTranscript() { } a.setCodeCopyBlocks(copyButtons) - a.transcript.SetContent(strings.Join(blocks, "\n\n")) - if atBottom || a.state.IsAgentRunning { + a.setTranscriptContent(strings.Join(blocks, "\n\n")) + if atBottom { a.transcript.GotoBottom() } } +func (a *App) setTranscriptContent(content string) { + normalized := normalizeTranscriptForDisplay(content) + a.transcriptContent = normalized + a.transcript.SetContent(normalized) +} + +func normalizeTranscriptForDisplay(content string) string { + return strings.ReplaceAll(content, "\t", " ") +} + func (a *App) rebuildActivity() { if len(a.activities) == 0 || a.activity.Height <= 0 { a.activity.SetContent("") @@ -2273,7 +2505,7 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot { } func (a *App) startDraftSession() { - a.state.ActiveSessionID = "" + a.setActiveSessionID("") a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil a.clearActivities() @@ -2357,6 +2589,209 @@ func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { ) } +func (a *App) setActiveSessionID(sessionID string) { + next := strings.TrimSpace(sessionID) + current := strings.TrimSpace(a.state.ActiveSessionID) + if strings.EqualFold(current, next) { + a.state.ActiveSessionID = next + return + } + if current != "" && a.logPersistDirty { + a.persistLogEntriesForActiveSession() + } + + previousEntries := a.logEntries + a.state.ActiveSessionID = next + if next == "" { + a.loadLogEntriesForSession("") + return + } + + loaded := a.readLogEntriesForSession(next) + if current == "" && len(previousEntries) > 0 { + loaded = append(loaded, previousEntries...) + loaded = clampLogEntries(loaded) + } + a.logEntries = loaded + a.logViewerOffset = 0 + a.clampLogViewerOffset() + if current == "" && len(previousEntries) > 0 { + a.persistLogEntriesForActiveSession() + } +} + +func (a *App) loadLogEntriesForSession(sessionID string) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + a.logEntries = nil + a.logViewerOffset = 0 + return + } + a.logEntries = a.readLogEntriesForSession(sessionID) + a.logViewerOffset = 0 + a.clampLogViewerOffset() +} + +func (a *App) readLogEntriesForSession(sessionID string) []logEntry { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil + } + runtimeWithPersistence := a.sessionLogRuntime() + if runtimeWithPersistence == nil { + return nil + } + entries, err := runtimeWithPersistence.LoadSessionLogEntries(context.Background(), sessionID) + if err != nil { + a.reportLogPersistenceError("load", err) + return nil + } + return clampLogEntries(fromRuntimeSessionLogEntries(entries)) +} + +func (a *App) persistLogEntriesForActiveSession() { + sessionID := strings.TrimSpace(a.state.ActiveSessionID) + if sessionID == "" { + a.logPersistDirty = false + return + } + + runtimeWithPersistence := a.sessionLogRuntime() + if runtimeWithPersistence == nil { + a.logPersistDirty = false + return + } + if err := runtimeWithPersistence.SaveSessionLogEntries( + context.Background(), + sessionID, + toRuntimeSessionLogEntries(clampLogEntries(a.logEntries)), + ); err != nil { + a.reportLogPersistenceError("save", err) + a.logPersistVersion++ + a.deferredLogPersistCmd = scheduleLogPersistFlush(a.logPersistVersion) + return + } + a.logPersistDirty = false +} + +// sessionLogRuntime 返回支持会话日志读写的 runtime 适配能力。 +func (a *App) sessionLogRuntime() sessionLogPersistenceRuntime { + runtimeWithPersistence, ok := a.runtime.(sessionLogPersistenceRuntime) + if !ok { + return nil + } + return runtimeWithPersistence +} + +// reportLogPersistenceError 统一处理日志持久化失败提示,避免错误静默吞掉。 +func (a *App) reportLogPersistenceError(action string, err error) { + if err == nil { + return + } + message := fmt.Sprintf("Failed to %s log entries: %v", strings.TrimSpace(action), err) + a.state.StatusText = message + a.showFooterError(message) +} + +// restoreStatusAfterLogViewer 在关闭日志视图时恢复可读状态,避免覆盖真实运行态。 +func (a *App) restoreStatusAfterLogViewer() { + defer func() { a.logViewerPrevStatus = "" }() + if executionError := strings.TrimSpace(a.state.ExecutionError); executionError != "" { + a.state.StatusText = executionError + return + } + if a.state.IsCompacting { + a.state.StatusText = statusCompacting + return + } + if a.state.IsAgentRunning { + if strings.TrimSpace(a.state.CurrentTool) != "" { + a.state.StatusText = statusRunningTool + } else { + a.state.StatusText = statusThinking + } + return + } + if prev := strings.TrimSpace(a.logViewerPrevStatus); prev != "" { + a.state.StatusText = prev + return + } + a.state.StatusText = statusReady +} + +// toRuntimeSessionLogEntries 转换日志条目到 runtime 持久化模型。 +func toRuntimeSessionLogEntries(entries []logEntry) []agentruntime.SessionLogEntry { + converted := make([]agentruntime.SessionLogEntry, 0, len(entries)) + for _, entry := range entries { + converted = append(converted, agentruntime.SessionLogEntry{ + Timestamp: entry.Timestamp, + Level: entry.Level, + Source: entry.Source, + Message: entry.Message, + }) + } + return converted +} + +// fromRuntimeSessionLogEntries 将 runtime 持久化模型恢复为 TUI 展示模型。 +func fromRuntimeSessionLogEntries(entries []agentruntime.SessionLogEntry) []logEntry { + converted := make([]logEntry, 0, len(entries)) + for _, entry := range entries { + converted = append(converted, logEntry{ + Timestamp: entry.Timestamp, + Level: entry.Level, + Source: entry.Source, + Message: entry.Message, + }) + } + return converted +} + +func clampLogEntries(entries []logEntry) []logEntry { + if len(entries) <= logViewerEntryLimit { + return entries + } + return append([]logEntry(nil), entries[len(entries)-logViewerEntryLimit:]...) +} + +func (a *App) clampLogViewerOffset() { + _, _, _, height := a.logViewerBounds() + maxOffset := a.logViewerMaxOffset(height) + if a.logViewerOffset > maxOffset { + a.logViewerOffset = maxOffset + } +} + +func (a App) transcriptMaxOffset() int { + return max(0, a.transcript.TotalLineCount()-a.transcript.VisibleLineCount()) +} + +func (a *App) setTranscriptOffsetFromScrollbarY(mouseY int) { + _, y, _, height := a.transcriptScrollbarBounds() + if height <= 0 { + return + } + maxOffset := a.transcriptMaxOffset() + if maxOffset <= 0 { + a.transcript.SetYOffset(0) + return + } + relative := mouseY - y + if relative < 0 { + relative = 0 + } + if relative >= height { + relative = height - 1 + } + + denominator := max(1, height-1) + target := (relative*maxOffset + denominator/2) / denominator + target = max(0, min(target, maxOffset)) + if target != a.transcript.YOffset { + a.transcript.SetYOffset(target) + } +} + // isBusy reports whether an agent run or compact operation is in progress. func (a App) isBusy() bool { return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) @@ -2824,7 +3259,7 @@ func sanitizeProviderAddInputRunes(runes []rune) string { return builder.String() } -// sanitizeProviderAddJSONInputRunes 过滤不可见格式控制字符,保留 JSON 编辑需要的换行与制表符。 +// sanitizeProviderAddJSONInputRunes 过滤不可见格式控制字符,同时保留 JSON 编辑所需的换行与制表符。 func sanitizeProviderAddJSONInputRunes(runes []rune) string { if len(runes) == 0 { return "" diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 53a8043a..f7cea97c 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -3,6 +3,7 @@ package tui import ( "context" "errors" + "fmt" "os" "path/filepath" "slices" @@ -121,6 +122,9 @@ type stubRuntime struct { listSessionsErr error loadSessions map[string]agentsession.Session loadSessionErr error + logEntriesBySID map[string][]agentruntime.SessionLogEntry + loadLogErr error + saveLogErr error } type snapshotRuntime struct { @@ -131,7 +135,10 @@ type snapshotRuntime struct { } func newStubRuntime() *stubRuntime { - return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} + return &stubRuntime{ + events: make(chan agentruntime.RuntimeEvent), + logEntriesBySID: make(map[string][]agentruntime.SessionLogEntry), + } } func (s *stubRuntime) PrepareUserInput(ctx context.Context, input agentruntime.PrepareInput) (agentruntime.UserInput, error) { @@ -228,6 +235,26 @@ func (s *stubRuntime) ListSessionSkills(ctx context.Context, sessionID string) ( return nil, nil } +func (s *stubRuntime) LoadSessionLogEntries(ctx context.Context, sessionID string) ([]agentruntime.SessionLogEntry, error) { + if s.loadLogErr != nil { + return nil, s.loadLogErr + } + entries := s.logEntriesBySID[strings.TrimSpace(sessionID)] + return append([]agentruntime.SessionLogEntry(nil), entries...), nil +} + +func (s *stubRuntime) SaveSessionLogEntries( + ctx context.Context, + sessionID string, + entries []agentruntime.SessionLogEntry, +) error { + if s.saveLogErr != nil { + return s.saveLogErr + } + s.logEntriesBySID[strings.TrimSpace(sessionID)] = append([]agentruntime.SessionLogEntry(nil), entries...) + return nil +} + func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { return agentsession.NewWithWorkdir("draft", workdir), nil } @@ -2197,8 +2224,8 @@ func TestMouseHandlersAndBounds(t *testing.T) { app.width = 120 app.height = 40 app.activities = []tuistate.ActivityEntry{{Kind: "test", Title: "activity"}} - app.transcript.SetContent(strings.Repeat("line\n", 100)) app.applyComponentLayout(true) + app.setTranscriptContent(strings.Repeat("line\n", 160)) tx, ty, _, _ := app.transcriptBounds() if !app.isMouseWithinTranscript(tea.MouseMsg{X: tx, Y: ty}) { @@ -2220,6 +2247,38 @@ func TestMouseHandlersAndBounds(t *testing.T) { }) { t.Fatalf("expected transcript wheel down to be handled") } + sx, sy, sw, sh := app.transcriptScrollbarBounds() + if sw != transcriptScrollbarWidth { + t.Fatalf("expected transcript scrollbar width %d, got %d", transcriptScrollbarWidth, sw) + } + if app.isMouseWithinTranscript(tea.MouseMsg{X: sx, Y: sy}) { + t.Fatalf("expected scrollbar column not to be counted as transcript content") + } + offsetBeforeDrag := app.transcript.YOffset + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: sx + 1, Y: sy + max(1, sh/2), Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) { + t.Fatalf("expected transcript scrollbar press to be handled") + } + if !app.transcriptScrollbarDrag { + t.Fatalf("expected scrollbar drag mode to start after press") + } + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: sx + 1, Y: sy + sh - 1, Action: tea.MouseActionMotion, + }) { + t.Fatalf("expected transcript scrollbar drag motion to be handled") + } + if app.transcript.YOffset <= offsetBeforeDrag { + t.Fatalf("expected drag motion to move transcript offset, got %d <= %d", app.transcript.YOffset, offsetBeforeDrag) + } + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: sx + 1, Y: sy + sh - 1, Action: tea.MouseActionRelease, + }) { + t.Fatalf("expected transcript scrollbar release to be handled") + } + if app.transcriptScrollbarDrag { + t.Fatalf("expected scrollbar drag mode to stop after release") + } ix, iy, _, _ := app.inputBounds() if !app.isMouseWithinInput(tea.MouseMsg{X: ix, Y: iy}) { @@ -2236,17 +2295,17 @@ func TestMouseHandlersAndBounds(t *testing.T) { } ax, ay, _, _ := app.activityBounds() - if !app.isMouseWithinActivity(tea.MouseMsg{X: ax, Y: ay}) { - t.Fatalf("expected activity bounds hit") + if app.isMouseWithinActivity(tea.MouseMsg{X: ax, Y: ay}) { + t.Fatalf("expected activity bounds miss when activity panel is disabled") } app.focus = panelTranscript - if !app.handleActivityMouse(tea.MouseMsg{ + if app.handleActivityMouse(tea.MouseMsg{ X: ax, Y: ay, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, }) { - t.Fatalf("expected activity wheel to be handled") + t.Fatalf("expected activity wheel to be ignored when activity panel is disabled") } - if app.focus != panelActivity { - t.Fatalf("expected activity panel to gain focus") + if app.focus != panelTranscript { + t.Fatalf("expected focus to stay on transcript when activity panel is disabled") } } @@ -3381,3 +3440,601 @@ func TestRunProviderAddFlowDeadlineExceededBranch(t *testing.T) { t.Fatalf("expected timeout error message, got %q", result.Error) } } + +func TestUpdateLogViewerModalKeyboardAndMouse(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + app.height = 14 + app.applyComponentLayout(true) + for i := 0; i < 24; i++ { + app.logEntries = append(app.logEntries, logEntry{ + Timestamp: time.Unix(int64(i), 0), + Level: "info", + Source: "test", + Message: "entry-" + string(rune('A'+i)), + }) + } + + app.logViewerOffset = 3 + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + app = model.(App) + if !app.logViewerVisible { + t.Fatalf("expected ctrl+l to open log viewer") + } + if app.logViewerOffset != 0 { + t.Fatalf("expected opening log viewer to reset offset, got %d", app.logViewerOffset) + } + + app.setTranscriptContent(strings.Repeat("line\n", 80)) + app.transcript.SetYOffset(7) + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = model.(App) + if app.logViewerOffset != 1 { + t.Fatalf("expected log viewer down key to scroll entries, got offset %d", app.logViewerOffset) + } + if app.transcript.YOffset != 7 { + t.Fatalf("expected transcript offset unchanged while log viewer visible, got %d", app.transcript.YOffset) + } + + x, y, _, _ := app.logViewerBounds() + model, _ = app.Update(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + app = model.(App) + if app.logViewerOffset != 2 { + t.Fatalf("expected log viewer mouse wheel to scroll entries, got offset %d", app.logViewerOffset) + } + if app.transcript.YOffset != 7 { + t.Fatalf("expected transcript y-offset to stay unchanged after log viewer wheel, got %d", app.transcript.YOffset) + } + + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app = model.(App) + if app.logViewerVisible { + t.Fatalf("expected esc to close log viewer") + } +} + +func TestSetTranscriptContentNormalizesTabStops(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + app.height = 40 + app.applyComponentLayout(true) + app.setTranscriptContent("a\tb") + if app.transcriptContent != "a b" { + t.Fatalf("expected tabs normalized in transcript content, got %q", app.transcriptContent) + } + if got := app.transcript.View(); !strings.Contains(got, "a b") { + t.Fatalf("expected normalized tabs in viewport content, got %q", got) + } +} + +func TestTranscriptManualScrollPersistsWhileBusy(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + app.height = 32 + app.applyComponentLayout(true) + app.activeMessages = make([]providertypes.Message, 0, 120) + for i := 0; i < 120; i++ { + app.activeMessages = append(app.activeMessages, providertypes.Message{ + Role: roleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("assistant-line-%03d", i))}, + }) + } + app.rebuildTranscript() + if app.transcriptMaxOffset() <= 6 { + t.Fatalf("expected transcript to be scrollable, max offset=%d", app.transcriptMaxOffset()) + } + app.transcript.SetYOffset(6) + app.state.IsAgentRunning = true + + app.rebuildTranscript() + if app.transcript.YOffset != 6 { + t.Fatalf("expected rebuildTranscript to keep manual offset while busy, got %d", app.transcript.YOffset) + } + + app.layoutCached = false + app.applyComponentLayout(false) + if app.transcript.YOffset != 6 { + t.Fatalf("expected applyComponentLayout to keep manual offset while busy, got %d", app.transcript.YOffset) + } +} + +func TestSessionLogViewerPersistenceAndCap(t *testing.T) { + app, runtime := newTestApp(t) + + app.setActiveSessionID("session-one") + for i := 0; i < 520; i++ { + app.addLogEntry("test", fmt.Sprintf("entry-%03d", i), "") + } + if len(app.logEntries) != logViewerEntryLimit { + t.Fatalf("expected %d capped entries, got %d", logViewerEntryLimit, len(app.logEntries)) + } + if !strings.Contains(app.logEntries[0].Message, "entry-020") { + t.Fatalf("expected oldest in-memory entry to be entry-020, got %q", app.logEntries[0].Message) + } + if app.deferredLogPersistCmd == nil { + t.Fatalf("expected deferred log persistence command to be queued") + } + model, _ := app.Update(logPersistFlushMsg{Version: app.logPersistVersion}) + app = model.(App) + if got := runtime.logEntriesBySID["session-one"]; len(got) != logViewerEntryLimit { + t.Fatalf("expected runtime persisted %d entries, got %d", logViewerEntryLimit, len(got)) + } + + app.setActiveSessionID("session-two") + app.addLogEntry("tool", "other", "detail") + if len(app.logEntries) != 1 { + t.Fatalf("expected session-two to start with its own log list, got %d entries", len(app.logEntries)) + } + + app.setActiveSessionID("session-one") + if len(app.logEntries) != logViewerEntryLimit { + t.Fatalf("expected loading session-one logs to restore %d entries, got %d", logViewerEntryLimit, len(app.logEntries)) + } + if !strings.Contains(app.logEntries[0].Message, "entry-020") { + t.Fatalf("expected restored oldest entry entry-020, got %q", app.logEntries[0].Message) + } + if !strings.Contains(app.logEntries[len(app.logEntries)-1].Message, "entry-519") { + t.Fatalf("expected restored newest entry entry-519, got %q", app.logEntries[len(app.logEntries)-1].Message) + } +} + +func TestSanitizeProviderAddJSONInputRunes(t *testing.T) { + input := []rune{'a', '\u200b', '\n', '\t', '\r', 0x01, 'b'} + got := sanitizeProviderAddJSONInputRunes(input) + if got != "a\n\tb" { + t.Fatalf("sanitizeProviderAddJSONInputRunes() = %q, want %q", got, "a\n\tb") + } +} + +func TestFooterErrorToastSyncBranches(t *testing.T) { + app, _ := newTestApp(t) + base := time.Unix(1_700_000_100, 0) + app.nowFn = func() time.Time { return base } + + app.showFooterError(" permission denied ") + if app.footerErrorText != "Error: permission denied" { + t.Fatalf("expected error prefix applied, got %q", app.footerErrorText) + } + if !app.footerErrorUntil.Equal(base.Add(footerErrorFlashDuration)) { + t.Fatalf("unexpected footer toast expiration: %v", app.footerErrorUntil) + } + + app.state.ExecutionError = "Runtime failed" + app.syncFooterErrorToast() + firstUntil := app.footerErrorUntil + if app.footerErrorLast != "Runtime failed" { + t.Fatalf("expected footerErrorLast to track latest execution error, got %q", app.footerErrorLast) + } + + app.nowFn = func() time.Time { return base.Add(5 * time.Second) } + app.state.ExecutionError = "runtime FAILED" + app.syncFooterErrorToast() + if !app.footerErrorUntil.Equal(firstUntil) { + t.Fatalf("expected equal-fold duplicate error to avoid refreshing toast timeout") + } + + app.state.ExecutionError = "" + app.syncFooterErrorToast() + if app.footerErrorLast != "" { + t.Fatalf("expected empty execution error to clear footerErrorLast") + } +} + +func TestHandleLogViewerKeyAndScrollBranches(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + app.logViewerVisible = true + for i := 0; i < 30; i++ { + app.logEntries = append(app.logEntries, logEntry{Timestamp: time.Unix(int64(i), 0), Level: "info", Source: "test", Message: "m"}) + } + + _, _, _, height := app.logViewerBounds() + app.logViewerOffset = app.logViewerMaxOffset(height) + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyHome}) + if app.logViewerOffset != 0 { + t.Fatalf("expected Home to jump to newest offset 0, got %d", app.logViewerOffset) + } + + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyEnd}) + if app.logViewerOffset != app.logViewerMaxOffset(height) { + t.Fatalf("expected End to jump to oldest offset, got %d", app.logViewerOffset) + } + + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyPgUp}) + if app.logViewerOffset > app.logViewerMaxOffset(height) { + t.Fatalf("expected PgUp offset to stay clamped, got %d", app.logViewerOffset) + } + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyPgDown}) + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyUp}) + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyDown}) + + app.handleLogViewerKey(tea.KeyMsg{Type: tea.KeyEsc}) + if app.logViewerVisible { + t.Fatalf("expected Esc to close log viewer") + } + if app.state.StatusText != statusReady { + t.Fatalf("expected status reset when closing log viewer, got %q", app.state.StatusText) + } +} + +func TestRestoreStatusAfterLogViewerUsesRuntimeState(t *testing.T) { + app, _ := newTestApp(t) + + app.logViewerPrevStatus = "Manual status" + app.state.ExecutionError = "runtime failed" + app.restoreStatusAfterLogViewer() + if app.state.StatusText != "runtime failed" { + t.Fatalf("expected execution error to win, got %q", app.state.StatusText) + } + + app.logViewerPrevStatus = "Manual status" + app.state.ExecutionError = "" + app.state.IsCompacting = true + app.restoreStatusAfterLogViewer() + if app.state.StatusText != statusCompacting { + t.Fatalf("expected compacting status, got %q", app.state.StatusText) + } + + app.logViewerPrevStatus = "Manual status" + app.state.IsCompacting = false + app.state.IsAgentRunning = true + app.state.CurrentTool = "bash" + app.restoreStatusAfterLogViewer() + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status, got %q", app.state.StatusText) + } +} + +func TestHandleLogViewerMouseAndClampOffset(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + app.logViewerVisible = true + for i := 0; i < 60; i++ { + app.logEntries = append(app.logEntries, logEntry{Timestamp: time.Unix(int64(i), 0), Level: "info", Source: "test", Message: "m"}) + } + + if !app.handleLogViewerMouse(tea.MouseMsg{X: 0, Y: 0, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) { + t.Fatalf("expected outside click to be treated as handled while log viewer is visible") + } + + x, y, w, h := app.logViewerBounds() + if w <= 2 || h <= 2 { + t.Fatalf("expected log viewer bounds to be drawable, got w=%d h=%d", w, h) + } + app.handleLogViewerMouse(tea.MouseMsg{ + X: x + w/2, + Y: y + h/2, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + if app.logViewerOffset != 1 { + t.Fatalf("expected wheel down to increase offset, got %d", app.logViewerOffset) + } + + app.logViewerOffset = 999 + app.clampLogViewerOffset() + _, _, _, height := app.logViewerBounds() + if app.logViewerOffset != app.logViewerMaxOffset(height) { + t.Fatalf("expected clampLogViewerOffset to constrain offset, got %d", app.logViewerOffset) + } +} + +func TestSetTranscriptOffsetFromScrollbarY(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 28 + app.applyComponentLayout(true) + app.setTranscriptContent(strings.Repeat("line\n", 200)) + app.transcript.SetYOffset(0) + + _, y, _, h := app.transcriptScrollbarBounds() + app.setTranscriptOffsetFromScrollbarY(y - 5) + if app.transcript.YOffset != 0 { + t.Fatalf("expected dragging above track to clamp to top, got %d", app.transcript.YOffset) + } + + app.setTranscriptOffsetFromScrollbarY(y + h + 10) + if app.transcript.YOffset != app.transcriptMaxOffset() { + t.Fatalf("expected dragging below track to clamp to bottom, got %d want %d", app.transcript.YOffset, app.transcriptMaxOffset()) + } +} + +func TestHandleTranscriptMouseDragSupportsMotionType(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 28 + app.applyComponentLayout(true) + app.setTranscriptContent(strings.Repeat("line\n", 200)) + + _, y, _, h := app.transcriptScrollbarBounds() + app.transcriptScrollbarDrag = true + before := app.transcript.YOffset + handled := app.handleTranscriptMouse(tea.MouseMsg{Y: y + h - 1, Type: tea.MouseMotion}) + if !handled { + t.Fatal("expected mouse motion type during drag to be handled") + } + if app.transcript.YOffset == before { + t.Fatalf("expected drag motion to update offset, still %d", app.transcript.YOffset) + } +} + +func TestSessionLogHelpersAndSwitchBootstrap(t *testing.T) { + app, _ := newTestApp(t) + + now := time.Unix(1_700_001_000, 0) + app.logEntries = []logEntry{{Timestamp: now, Level: "info", Source: "bootstrap", Message: "in-memory"}} + runtime := app.runtime.(*stubRuntime) + runtime.logEntriesBySID["session-A"] = []agentruntime.SessionLogEntry{ + {Timestamp: now, Level: "info", Source: "file", Message: "persisted"}, + } + + app.setActiveSessionID("session-A") + if len(app.logEntries) != 2 { + t.Fatalf("expected bootstrap switch to merge file + in-memory entries, got %d", len(app.logEntries)) + } + + app.setActiveSessionID("") + if len(app.logEntries) != 0 || app.logViewerOffset != 0 { + t.Fatalf("expected clearing active session to reset log state") + } + + runtime.loadLogErr = errors.New("decode failed") + if got := app.readLogEntriesForSession("session-invalid"); got != nil { + t.Fatalf("expected load error branch to return nil entries, got %+v", got) + } +} + +func TestAppendActivityCapsAndFooterError(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + + for i := 0; i < maxActivityEntries+3; i++ { + app.appendActivity("tool", fmt.Sprintf("warn-%03d", i), "detail", false) + } + if len(app.activities) != maxActivityEntries { + t.Fatalf("expected activities capped at %d, got %d", maxActivityEntries, len(app.activities)) + } + + app.showFooterError(" ") + before := app.footerErrorText + app.appendActivity("tool", "failed-run", "", true) + if app.footerErrorText == before || !strings.Contains(app.footerErrorText, "Error:") { + t.Fatalf("expected error activity to refresh footer toast, got %q", app.footerErrorText) + } +} + +func TestAddLogEntryWarnAndOffsetClamp(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + app.logViewerOffset = 999 + + app.addLogEntry("tool", "Warn threshold", "almost full") + if len(app.logEntries) == 0 || app.logEntries[len(app.logEntries)-1].Level != "warn" { + t.Fatalf("expected warn title to map to warn log level") + } + if app.logViewerOffset > app.logViewerMaxOffset(app.logViewerRows(app.height)) { + t.Fatalf("expected log viewer offset to be clamped, got %d", app.logViewerOffset) + } +} + +func TestHandleLogViewerMouseWheelUpAndScrollClamp(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + app.logViewerVisible = true + for i := 0; i < 60; i++ { + app.logEntries = append(app.logEntries, logEntry{Timestamp: time.Unix(int64(i), 0), Level: "info", Source: "test", Message: "m"}) + } + _, _, _, h := app.logViewerBounds() + app.logViewerOffset = 5 + + x, y, w, height := app.logViewerBounds() + app.handleLogViewerMouse(tea.MouseMsg{ + X: x + w/2, + Y: y + height/2, + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + }) + if app.logViewerOffset != 4 { + t.Fatalf("expected wheel up to decrease offset, got %d", app.logViewerOffset) + } + + app.scrollLogViewer(0, h) + if app.logViewerOffset != 4 { + t.Fatalf("expected zero-delta scroll to keep offset unchanged, got %d", app.logViewerOffset) + } + app.scrollLogViewer(-1000, h) + if app.logViewerOffset != 0 { + t.Fatalf("expected large negative scroll to clamp to 0, got %d", app.logViewerOffset) + } + app.scrollLogViewer(1000, h) + if app.logViewerOffset != app.logViewerMaxOffset(h) { + t.Fatalf("expected large positive scroll to clamp to max offset, got %d", app.logViewerOffset) + } +} + +func TestMouseHitHelpersGuardWhenBoundsZero(t *testing.T) { + app, _ := newTestApp(t) + app.width = 0 + app.height = 0 + app.transcript.Width = 0 + app.transcript.Height = 0 + + msg := tea.MouseMsg{X: 0, Y: 0} + if app.isMouseWithinTranscriptScrollbar(msg) { + t.Fatalf("expected transcript scrollbar hit test to fail when bounds are zero") + } + if app.isMouseWithinLogViewer(msg) { + t.Fatalf("expected log viewer hit test to fail when bounds are zero") + } +} + +func TestReadAndPersistLogEntriesGuardBranches(t *testing.T) { + app, _ := newTestApp(t) + if got := app.readLogEntriesForSession(" "); got != nil { + t.Fatalf("expected blank session id to return nil log entries, got %+v", got) + } + + app.state.ActiveSessionID = "" + app.persistLogEntriesForActiveSession() +} + +func TestPersistLogEntriesRetryOnSaveFailure(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-save-error" + app.logEntries = []logEntry{{Timestamp: time.Now(), Level: "info", Source: "test", Message: "m"}} + app.logPersistDirty = true + app.logPersistVersion = 1 + runtime.saveLogErr = errors.New("disk full") + + app.persistLogEntriesForActiveSession() + if !app.logPersistDirty { + t.Fatal("expected dirty flag to remain true after save failure") + } + if app.deferredLogPersistCmd == nil { + t.Fatal("expected deferred persist command to be rescheduled on save failure") + } +} + +func TestUpdateFocusInputNewSessionAndTodoScroll(t *testing.T) { + app, runtime := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + + app.focus = panelTranscript + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app = model.(App) + if app.focus != panelInput { + t.Fatalf("expected Esc to focus input panel") + } + + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + app = model.(App) + if len(runtime.listSessions) != 0 && strings.TrimSpace(app.state.ActiveSessionID) == "" { + t.Fatalf("expected Ctrl+N to create or activate draft session") + } + + app.focus = panelTodo + app.todoItems = []todoViewItem{ + {ID: "1", Title: "a", Status: "pending"}, + {ID: "2", Title: "b", Status: "pending"}, + } + app.todoSelectedIndex = 1 + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyUp}) + app = model.(App) + if app.todoSelectedIndex != 0 { + t.Fatalf("expected todo selection to move up, got %d", app.todoSelectedIndex) + } +} + +func TestActivateSessionByIDAndCompactDoneInvalidPayload(t *testing.T) { + app, _ := newTestApp(t) + app.state.Sessions = []agentsession.Summary{{ID: "s1", Title: "Session 1"}} + if err := app.activateSessionByID("s1"); err != nil { + t.Fatalf("activateSessionByID() error = %v", err) + } + + if handled := runtimeEventCompactDoneHandler(&app, agentruntime.RuntimeEvent{Payload: "invalid"}); handled { + t.Fatalf("expected compact done handler to ignore invalid payload") + } +} + +func TestHandleTranscriptMouseWheelAndClickFallback(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + app.setTranscriptContent(strings.Repeat("line\n", 120)) + app.transcript.SetYOffset(20) + + x, y, w, h := app.transcriptBounds() + if w <= 2 || h <= 2 { + t.Fatalf("expected transcript bounds to be drawable, got w=%d h=%d", w, h) + } + + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: x + w/2, + Y: y + h/2, + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + }) { + t.Fatalf("expected transcript wheel up to be handled") + } + + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: x + w/2, + Y: y + h/2, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) { + t.Fatalf("expected transcript wheel down to be handled") + } + + app.pendingCopyID = 9 + if app.handleTranscriptMouse(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) { + t.Fatalf("expected plain left click without copy button hit to return false") + } + if app.pendingCopyID != 0 { + t.Fatalf("expected pendingCopyID reset when click does not hit copy button, got %d", app.pendingCopyID) + } +} + +func TestInputBoundsAndTranscriptOffsetGuardBranches(t *testing.T) { + app, _ := newTestApp(t) + app.width = 0 + app.height = 0 + if app.isMouseWithinInput(tea.MouseMsg{X: 0, Y: 0}) { + t.Fatalf("expected input hit test to fail when layout is zero-sized") + } + + app.width = 100 + app.height = 24 + app.applyComponentLayout(true) + app.transcript.Height = 0 + app.setTranscriptOffsetFromScrollbarY(10) + + app.transcript.Height = 8 + app.setTranscriptContent("short\n") + app.transcript.SetYOffset(5) + _, y, _, _ := app.transcriptScrollbarBounds() + app.setTranscriptOffsetFromScrollbarY(y + 1) + if app.transcript.YOffset != 0 { + t.Fatalf("expected maxOffset<=0 branch to reset transcript y-offset, got %d", app.transcript.YOffset) + } +} + +func TestRebuildActivityWithHeightAndPersistPathGuard(t *testing.T) { + app, _ := newTestApp(t) + app.activity.Width = 30 + app.activity.Height = 5 + app.activities = []tuistate.ActivityEntry{ + {Kind: "tool", Title: "run", Detail: "ok"}, + } + app.rebuildActivity() + if strings.TrimSpace(app.activity.View()) == "" { + t.Fatalf("expected rebuildActivity to render entries when viewport height is available") + } + + app.state.ActiveSessionID = "___" + app.persistLogEntriesForActiveSession() +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index f1cd4f97..5c07d69b 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" @@ -19,6 +20,7 @@ type layout struct { } const headerBarHeight = 2 +const transcriptScrollbarWidth = 3 const ( pickerPanelHorizontalInset = 8 @@ -75,7 +77,8 @@ func (a App) renderHeader(width int) string { status = tuiutils.Fallback(status, statusReady) model := tuiutils.Fallback(strings.TrimSpace(a.state.CurrentModel), "unknown-model") - headerText := fmt.Sprintf("NeoCode | %s | %s", model, status) + workdir := tuiutils.Fallback(strings.TrimSpace(a.state.CurrentWorkdir), "-") + headerText := fmt.Sprintf("NeoCode | %s | %s | cwd: %s", model, status, workdir) if lipgloss.Width(headerText) > width { headerText = tuiutils.TrimMiddle(headerText, max(8, width)) } @@ -88,10 +91,11 @@ func (a App) renderBody(lay layout) string { // waterfallMetrics 统一计算瀑布区各组件高度,确保渲染、布局与命中区域使用同一组尺寸。 func (a App) waterfallMetrics(width int, height int) (int, int, int, int) { - activityHeight := a.activityPreviewHeight() + activityHeight := 0 todoHeight := a.todoPreviewHeight() menuHeight := a.commandMenuHeight(width) - transcriptHeight := max(6, height-activityHeight-todoHeight-menuHeight) + promptHeight := lipgloss.Height(a.renderPrompt(width)) + transcriptHeight := max(6, height-activityHeight-todoHeight-menuHeight-promptHeight) return transcriptHeight, activityHeight, menuHeight, todoHeight } @@ -107,9 +111,12 @@ func (a App) renderWaterfall(width int, height int) string { ) } - transcriptHeight, _, _, _ := a.waterfallMetrics(width, height) + if a.logViewerVisible { + return a.renderLogViewer(width, height) + } - transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) + transcriptContent := a.transcript.View() + transcript := a.renderTranscriptWithScrollbar(width, transcriptContent) parts := []string{transcript} if a.state.IsAgentRunning && a.state.StatusText == statusThinking { @@ -118,9 +125,6 @@ func (a App) renderWaterfall(width int, height int) string { Italic(true) parts = append(parts, thinkingStyle.Render("Thinking...")) } - if activity := a.renderActivityPreview(width); activity != "" { - parts = append(parts, activity) - } if todo := a.renderTodoPreview(width); todo != "" { parts = append(parts, todo) } @@ -133,6 +137,59 @@ func (a App) renderWaterfall(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } +func (a App) renderTranscriptWithScrollbar(totalWidth int, content string) string { + scrollbarWidth := a.transcriptScrollbarWidth(totalWidth) + if scrollbarWidth <= 0 { + return a.styles.streamContent.Render(content) + } + + contentWidth := max(1, totalWidth-scrollbarWidth) + contentView := a.styles.streamContent.Width(contentWidth).Render(content) + scrollbar := a.renderTranscriptScrollbar(scrollbarWidth, max(1, a.transcript.Height)) + return lipgloss.JoinHorizontal(lipgloss.Top, contentView, scrollbar) +} + +func (a App) transcriptScrollbarWidth(totalWidth int) int { + if totalWidth <= transcriptScrollbarWidth { + return 0 + } + return transcriptScrollbarWidth +} + +func (a App) renderTranscriptScrollbar(width int, height int) string { + if width <= 0 || height <= 0 { + return "" + } + + track := strings.Repeat(" ", width) + trackStyle := lipgloss.NewStyle().Background(lipgloss.Color(purpleBg2)) + thumbStyle := lipgloss.NewStyle().Background(lipgloss.Color(purpleAccent)) + + maxOffset := a.transcriptMaxOffset() + thumbHeight := height + thumbTop := 0 + + if maxOffset > 0 { + totalLines := max(1, a.transcript.TotalLineCount()) + visibleLines := max(1, a.transcript.VisibleLineCount()) + thumbHeight = max(1, min(height, (visibleLines*height+totalLines-1)/totalLines)) + if height > thumbHeight { + thumbTop = (a.transcript.YOffset*(height-thumbHeight) + maxOffset/2) / maxOffset + thumbTop = max(0, min(thumbTop, height-thumbHeight)) + } + } + + lines := make([]string, 0, height) + for row := 0; row < height; row++ { + if row >= thumbTop && row < thumbTop+thumbHeight { + lines = append(lines, thumbStyle.Render(track)) + continue + } + lines = append(lines, trackStyle.Render(track)) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + func (a App) buildPickerLayout(contentWidth int, contentHeight int) pickerLayoutSpec { panelWidth := tuiutils.Clamp(contentWidth-pickerPanelHorizontalInset, pickerPanelMinWidth, pickerPanelMaxWidth) panelHeight := tuiutils.Clamp(contentHeight-pickerPanelVerticalInset, pickerPanelMinHeight, pickerPanelMaxHeight) @@ -453,8 +510,33 @@ func (a App) commandMenuHeight(width int) int { func (a App) renderHelp(width int) string { a.help.ShowAll = a.state.ShowHelp helpContent := a.help.View(a.keys) + lines := []string{} + if errLine := a.footerErrorLine(width); errLine != "" { + lines = append(lines, errLine) + } + lines = append(lines, helpContent) + footerContent := strings.Join(lines, "\n") // Keep help content stretched to full width to avoid clipping at borders. - return a.styles.footer.Width(width).Render(helpContent) + return a.styles.footer.Width(width).Render(footerContent) +} + +func (a App) footerErrorLine(width int) string { + if width <= 0 { + return "" + } + + message := strings.TrimSpace(a.footerErrorText) + if message == "" { + return "" + } + if !a.footerErrorUntil.IsZero() && a.now().After(a.footerErrorUntil) { + return "" + } + + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(errorRed)). + Width(width). + Render(compactStatusText(message, max(8, width))) } func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle lipgloss.Style, startCopyID int) (string, []copyCodeButtonBinding) { @@ -555,23 +637,15 @@ func (a App) focusLabel() string { } func (a App) activityPreviewHeight() int { - return tuicomponents.ActivityPreviewHeight(len(a.activities)) + return 0 } func (a App) renderActivityPreview(width int) string { - if len(a.activities) == 0 { - return "" - } - content := a.activity.View() - - return a.renderPanel( - activityTitle, - activitySubtitle, - content, - width, - a.activityPreviewHeight(), - a.focus == panelActivity, - ) + _ = a + _ = width + _ = activityTitle + _ = activitySubtitle + return "" } func (a App) renderActivityLine(entry tuistate.ActivityEntry, width int) string { @@ -588,6 +662,77 @@ func (a App) computeLayout() layout { // helpHeight 仅计算帮助区高度,避免在 layout 计算阶段触发完整渲染。 func (a App) helpHeight(width int) int { - a.help.ShowAll = a.state.ShowHelp - return lipgloss.Height(a.styles.footer.Width(width).Render(a.help.View(a.keys))) + return lipgloss.Height(a.renderHelp(width)) +} + +func (a App) renderLogViewer(width int, height int) string { + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(purpleAccent)). + Bold(true). + Width(max(1, width-4)) + + headerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(oliveGray)). + Width(max(1, width-4)) + + timeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(oliveGray)). + Width(20) + + levelStyle := lipgloss.NewStyle(). + Width(8) + + sourceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(lightText)). + Width(15) + + msgStyle := lipgloss.NewStyle() + + lines := []string{ + titleStyle.Render(" Log Viewer "), + headerStyle.Render(" Time Level Source Message"), + "", + } + + maxOffset := a.logViewerMaxOffset(height) + offset := max(0, min(a.logViewerOffset, maxOffset)) + rows := a.logViewerRows(height) + + if len(a.logEntries) == 0 { + lines = append(lines, headerStyle.Render(" No log entries")) + } else { + for row := 0; row < rows; row++ { + i := len(a.logEntries) - 1 - (offset + row) + if i < 0 { + break + } + entry := a.logEntries[i] + ts := entry.Timestamp.Format("15:04:05") + level := ansi.Cut(entry.Level, 0, 8) + source := ansi.Cut(entry.Source, 0, 15) + msg := entry.Message + msgWidth := max(0, width-50) + if msgWidth > 0 && ansi.StringWidth(msg) > msgWidth { + msg = ansi.Cut(msg, 0, msgWidth) + } + if msgWidth == 0 { + msg = "" + } + lines = append(lines, timeStyle.Render(ts)+" "+levelStyle.Render(level)+" "+sourceStyle.Render(source)+" "+msgStyle.Render(msg)) + } + } + + positionCurrent := 0 + positionTotal := 0 + if len(a.logEntries) > 0 { + positionCurrent = offset + 1 + positionTotal = maxOffset + 1 + } + lines = append(lines, "") + lines = append(lines, headerStyle.Render(fmt.Sprintf(" Use Up/Down/PgUp/PgDn to scroll (%d/%d) · Ctrl+L or Esc to close", positionCurrent, positionTotal))) + + content := lipgloss.JoinVertical(lipgloss.Left, lines...) + + panelStyle := a.styles.panelFocused.Width(width).Height(height) + return panelStyle.Render(content) } diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 01c7c31c..e2ef3fef 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -142,7 +142,7 @@ func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing app.applyComponentLayout(false) lay := app.computeLayout() - wantTranscriptHeight, activityHeight, menuHeight, _ := app.waterfallMetrics(app.transcript.Width, lay.contentHeight) + wantTranscriptHeight, activityHeight, menuHeight, todoHeight := app.waterfallMetrics(lay.contentWidth, lay.contentHeight) if app.transcript.Height != wantTranscriptHeight { t.Fatalf("expected transcript height %d, got %d", wantTranscriptHeight, app.transcript.Height) } @@ -162,6 +162,10 @@ func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing if inputY != transcriptY+wantTranscriptHeight+activityHeight+menuHeight { t.Fatalf("expected input Y %d, got %d", transcriptY+wantTranscriptHeight+activityHeight+menuHeight, inputY) } + promptHeight := lipgloss.Height(app.renderPrompt(lay.contentWidth)) + if usedHeight := wantTranscriptHeight + activityHeight + todoHeight + menuHeight + promptHeight; usedHeight > lay.contentHeight { + t.Fatalf("expected waterfall stack to fit content area, used %d > %d", usedHeight, lay.contentHeight) + } } func TestComputeLayoutUsesRenderedHeaderHeight(t *testing.T) { @@ -341,6 +345,7 @@ func TestViewNormalIncludesHeaderAndBody(t *testing.T) { app.width = 100 app.height = 30 app.state.CurrentModel = "test-model" + app.state.CurrentWorkdir = "/tmp/workdir" app.state.StatusText = "running" app.state.IsAgentRunning = true app.runProgressKnown = true @@ -359,6 +364,9 @@ func TestViewNormalIncludesHeaderAndBody(t *testing.T) { if !strings.Contains(view, "42% loading") { t.Fatalf("expected progress header, got %q", view) } + if !strings.Contains(view, "cwd: /tmp/workdir") { + t.Fatalf("expected current workdir in header, got %q", view) + } } func TestViewAddsSpacerWhenDocIsTallerThanContent(t *testing.T) { @@ -376,6 +384,7 @@ func TestRenderHeaderFallbackAndTrim(t *testing.T) { app, _ := newTestApp(t) app.state.IsAgentRunning = true app.state.StatusText = "custom-running-status" + app.state.CurrentWorkdir = "/tmp/workdir" header := app.renderHeader(20) if strings.TrimSpace(header) == "" { t.Fatalf("expected non-empty header") @@ -388,6 +397,18 @@ func TestRenderHeaderFallbackAndTrim(t *testing.T) { } } +func TestRenderHeaderIncludesWorkdirFallback(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentModel = "test-model" + app.state.StatusText = statusReady + app.state.CurrentWorkdir = "" + + header := app.renderHeader(120) + if !strings.Contains(header, "cwd: -") { + t.Fatalf("expected workdir fallback in header, got %q", header) + } +} + func TestRenderPanelAndActivityPreview(t *testing.T) { app, _ := newTestApp(t) panel := app.renderPanel("Title", "Sub", "Body", 60, 8, true) @@ -400,8 +421,8 @@ func TestRenderPanelAndActivityPreview(t *testing.T) { } app.activities = []tuistate.ActivityEntry{{Kind: "tool", Title: "Run", Detail: "Detail"}} withActivity := app.renderActivityPreview(60) - if !strings.Contains(withActivity, activityTitle) { - t.Fatalf("expected activity panel title, got %q", withActivity) + if withActivity != "" { + t.Fatalf("expected activity preview disabled even with entries, got %q", withActivity) } app.commandMenu.SetItems([]list.Item{ @@ -409,11 +430,47 @@ func TestRenderPanelAndActivityPreview(t *testing.T) { }) app.commandMenuMeta = tuistate.CommandMenuMeta{Title: commandMenuTitle} withMenu := app.renderWaterfall(80, 24) + if strings.Contains(withMenu, activityTitle) { + t.Fatalf("expected waterfall to exclude activity panel, got %q", withMenu) + } if !strings.Contains(withMenu, commandMenuTitle) { t.Fatalf("expected command menu to be rendered") } } +func TestRenderHelpShowsCtrlLAndError(t *testing.T) { + app, _ := newTestApp(t) + app.state.StatusText = statusReady + rendered := 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) + if !strings.Contains(rendered, "Error: permission denied") { + t.Fatalf("expected footer to surface execution error, got %q", rendered) + } +} + +func TestRenderHelpErrorToastExpires(t *testing.T) { + app, _ := newTestApp(t) + base := time.Unix(1_700_000_000, 0) + app.nowFn = func() time.Time { return base } + + app.showFooterError("permission denied") + rendered := 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) + if strings.Contains(rendered, "Error: permission denied") { + t.Fatalf("expected footer toast to auto-hide after flash duration, got %q", rendered) + } +} + func TestRenderMessageContentWithCopyBranches(t *testing.T) { app, _ := newTestApp(t) @@ -543,3 +600,107 @@ func TestNormalizeAndTrimHelpers(t *testing.T) { t.Fatalf("expected two lines, got %q", normalized) } } + +func TestRenderLogViewerHonorsOffset(t *testing.T) { + app, _ := newTestApp(t) + for i := 0; i < 6; i++ { + app.logEntries = append(app.logEntries, logEntry{ + Timestamp: time.Unix(int64(i), 0), + Level: "info", + Source: "test", + Message: "msg-" + string(rune('A'+i)), + }) + } + + app.logViewerOffset = 0 + view := app.renderLogViewer(80, 6) + if !strings.Contains(view, "msg-F") { + t.Fatalf("expected newest log message at offset 0, got %q", view) + } + + app.logViewerOffset = 2 + view = app.renderLogViewer(80, 6) + if !strings.Contains(view, "msg-D") { + t.Fatalf("expected older log message at offset 2, got %q", view) + } +} + +func TestRenderActivityLineAndScrollbarHelpers(t *testing.T) { + app, _ := newTestApp(t) + + line := app.renderActivityLine(tuistate.ActivityEntry{ + Time: time.Unix(1_700_000_000, 0), + Kind: "tool", + Title: "Run", + Detail: "details", + IsError: false, + }, 72) + if strings.TrimSpace(line) == "" { + t.Fatalf("expected renderActivityLine to return non-empty text") + } + + if got := app.transcriptScrollbarWidth(3); got != 0 { + t.Fatalf("expected narrow transcript width to disable scrollbar, got %d", got) + } + if got := app.transcriptScrollbarWidth(20); got != transcriptScrollbarWidth { + t.Fatalf("expected transcript scrollbar width %d, got %d", transcriptScrollbarWidth, got) + } + + if got := app.renderTranscriptScrollbar(0, 10); got != "" { + t.Fatalf("expected empty scrollbar when width is zero, got %q", got) + } + if got := app.renderTranscriptScrollbar(2, 0); got != "" { + t.Fatalf("expected empty scrollbar when height is zero, got %q", got) + } + + app.transcript.Width = 20 + app.transcript.Height = 5 + app.transcript.SetContent(strings.Repeat("line\n", 30)) + app.transcript.SetYOffset(3) + if got := app.renderTranscriptScrollbar(2, 5); got == "" { + t.Fatalf("expected non-empty scrollbar when transcript is scrollable") + } +} + +func TestRenderLogViewerEmptyAndNarrowWidthBranches(t *testing.T) { + app, _ := newTestApp(t) + + empty := app.renderLogViewer(60, 8) + if !strings.Contains(empty, "No log entries") { + t.Fatalf("expected empty log viewer hint, got %q", empty) + } + + app.logEntries = []logEntry{ + { + Timestamp: time.Unix(1_700_000_100, 0), + Level: "warning", + Source: "source-with-long-name", + Message: "long message that should be truncated or hidden in narrow layouts", + }, + } + narrow := app.renderLogViewer(45, 8) + if strings.Contains(narrow, "long message") { + t.Fatalf("expected message text hidden when message width is zero, got %q", narrow) + } + + wide := app.renderLogViewer(70, 8) + if !strings.Contains(wide, "Use Up/Down/PgUp/PgDn to scroll") { + t.Fatalf("expected scroll hint in log viewer footer, got %q", wide) + } +} + +func TestRenderWaterfallAndTranscriptWithoutScrollbar(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerNone + app.logViewerVisible = true + logView := app.renderWaterfall(80, 20) + if !strings.Contains(logView, "Log Viewer") { + t.Fatalf("expected log viewer branch in waterfall, got %q", logView) + } + + app.logViewerVisible = false + plain := app.renderTranscriptWithScrollbar(2, "hello") + if strings.TrimSpace(plain) == "" { + t.Fatalf("expected transcript to render without scrollbar in very narrow width") + } +} diff --git a/internal/tui/state/constants.go b/internal/tui/state/constants.go index 3205ff91..ecb23654 100644 --- a/internal/tui/state/constants.go +++ b/internal/tui/state/constants.go @@ -5,7 +5,7 @@ import "time" // 这些常量定义了输入框与粘贴检测在 Update 流程中的基础行为阈值。 const ( ComposerMinHeight = 1 - ComposerMaxHeight = 5 + ComposerMaxHeight = 10 ComposerPromptWidth = 2 MouseWheelStepLines = 3 PasteBurstWindow = 120 * time.Millisecond