diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 35f555b2..8e35902a 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -291,12 +291,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } if key.Matches(typed, a.keys.FocusInput) { - if a.logViewerVisible { - a.logViewerVisible = false - a.state.StatusText = statusReady - a.applyComponentLayout(false) - return a, tea.Batch(cmds...) - } a.focus = panelInput a.applyFocus() return a, tea.Batch(cmds...) @@ -315,16 +309,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if key.Matches(typed, a.keys.LogViewer) { - a.logViewerVisible = !a.logViewerVisible - if a.logViewerVisible { - a.logViewerOffset = 0 - } + a.logViewerVisible = true + a.logViewerOffset = 0 a.viewDirty = true - if a.logViewerVisible { - a.state.StatusText = "Log viewer" - } else { - a.state.StatusText = statusReady - } + a.state.StatusText = "Log viewer" a.applyComponentLayout(false) return a, tea.Batch(cmds...) } @@ -516,7 +504,8 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } // image capability precheck is intentionally disabled. - // 淇濇寔涓?CLI 涓€鑷达紝鍏堝厑璁歌緭鍏ユ彁浜ゆ祦杞紝鍐嶇敱鍚庣画閾捐矾缁熶竴澶勭悊鑳藉姏鍏滃簳銆? a.input.Reset() + // 保持与 CLI 一致,先允许输入提交流转,再由后续链路统一处理能力兜底。 + a.input.Reset() a.state.InputText = "" a.applyComponentLayout(true) a.refreshCommandMenu() @@ -1010,7 +999,7 @@ func (a *App) refreshRuntimeSourceSnapshot() { } } -// runtimeSessionContextSource 缂傚倷鐒﹂幏婵嬫⒔閸曨偒鐒芥い鎰剁畱閻銇勯弽顐汗闁稿鎸诲鍕沪閸撗勬濠电偞娼欓崥瀣┍濞差亝鍎婇柤鎭掑劤閳绘柨鈹戦悩杈厡闁荤喐鎹囬弻锟犲磼濮橆厼顦╅梺缁樻惈缁辨洟骞忛悩璇差潊闁挎繂鎳庨獮瀣⒑閸涘﹥鈷愮紒瀣箻閸┾偓 +// runtimeSessionContextSource 定义读取会话上下文快照的最小接口,便于在 UI 侧按需刷新运行态信息。 type runtimeSessionContextSource interface { GetSessionContext(ctx context.Context, sessionID string) (any, error) } @@ -1064,7 +1053,7 @@ func runtimeEventPhaseChangedHandler(a *App, event agentruntime.RuntimeEvent) bo return false } -// runtimeEventStopReasonDecidedHandler 濠电姰鍨煎▔娑氣偓姘煎櫍楠炲啯绻濋崶褑鍩炲銈嗙墬閻熝囶敂鏉堚晝纾藉ù锝呮憸婢ф稒銇勯幒鎾垛姇缂佸倸绉堕埀顒婄秵娴滅偤寮堕挊澹濈懓顭ㄩ崘鎯у壆濠电偛妫庨崹鑺ヤ繆 +// runtimeEventStopReasonDecidedHandler 在运行结束原因落地后统一收敛状态与提示信息。 func runtimeEventStopReasonDecidedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.StopReasonDecidedPayload) if !ok { @@ -1341,7 +1330,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 @@ -1351,7 +1340,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 @@ -1387,7 +1376,7 @@ func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool return true } -// runtimeEventAgentChunkHandler 濠电姰鍨煎▔娑氣偓姘煎櫍楠炲啯绻濋崑鐣屽枛閸ㄦ儳鐣烽崶锝呬壕鐎瑰嫭鍣寸憴鍕闁告縿鍎抽、鍛節閳封偓閸涱厺妲愰梺闈╃悼閸庛倕顭囪箛娑樼闁告劕寮堕惁鏃堟⒑ +// runtimeEventAgentChunkHandler 将流式回复分片持续追加到转录区,并推进运行进度。 func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(string) if !ok { @@ -1408,7 +1397,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 @@ -1442,7 +1431,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 @@ -1527,7 +1516,7 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve return false } -// refreshPermissionPromptLayout 闂備線娼荤拹鐔煎礉瀹€鈧划鈺傤槹鎼存繃鍕冮梺閫炲苯澧い鏂跨箻楠炴捇骞掗幋鐘辨樊濠电姵顔栭崰鏍敄閸涱垪鍋撻崹顐ゅ弨鐎殿噮鍠氶幑鍕传閸曨厼骞堥梻浣告惈閸婄粯鏅跺Δ鈧…鍥醇閵夛腹鎸冮梺瑙勫婢ф顩奸妸鈺傜厸濠㈣泛瀛╃涵鑸电箾閺夋埈妯€鐎规洘顨婇幖褰掝敃閵忋垻鎳囬梻浣虹帛椤ㄥ懘鎮ч崟顖氱劦 +// refreshPermissionPromptLayout 在权限提示出现或消失后刷新布局,避免遮挡输入区。 func (a *App) refreshPermissionPromptLayout() { if a.width <= 0 || a.height <= 0 { return @@ -2217,20 +2206,9 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { 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 @@ -2649,10 +2627,7 @@ func (a *App) persistLogEntriesForActiveSession() { if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { return } - payload, err := json.Marshal(clampLogEntries(a.logEntries)) - if err != nil { - return - } + payload, _ := json.Marshal(clampLogEntries(a.logEntries)) _ = os.WriteFile(logPath, payload, 0o600) } @@ -3176,7 +3151,7 @@ func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, str return request, "" } -// sanitizeProviderAddInputRunes 杩囨护 provider 琛ㄥ崟杈撳叆涓殑鎺у埗瀛楃锛岄伩鍏嶄笉鍙瀛楃姹℃煋閰嶇疆瀛楁銆 +// sanitizeProviderAddInputRunes 过滤 provider 表单输入中的控制字符,避免不可见字符污染配置字段。 func sanitizeProviderAddInputRunes(runes []rune) string { if len(runes) == 0 { return "" @@ -3193,7 +3168,7 @@ func sanitizeProviderAddInputRunes(runes []rune) string { return builder.String() } -// sanitizeProviderAddJSONInputRunes 杩囨护涓嶅彲瑙佹牸寮忔帶鍒跺瓧绗︼紝淇濈暀 JSON 缂栬緫闇€瑕佺殑鎹㈣涓庡埗琛ㄧ銆 +// sanitizeProviderAddJSONInputRunes 过滤不可见格式控制字符,同时保留 JSON 编辑所需的换行与制表符。 func sanitizeProviderAddJSONInputRunes(runes []rune) string { if len(runes) == 0 { return "" @@ -3216,7 +3191,7 @@ func sanitizeProviderAddJSONInputRunes(runes []rune) string { return builder.String() } -// normalizeProviderAddFieldValue 瀵?provider 琛ㄥ崟瀛楁鍋氱粺涓€娓呯悊锛屽幓闄ゆ帶鍒跺瓧绗﹀苟瑁佸壀棣栧熬绌虹櫧銆 +// normalizeProviderAddFieldValue 对 provider 表单字段做统一清理,去除控制字符并裁剪首尾空白。 func normalizeProviderAddFieldValue(value string) string { return strings.TrimSpace(sanitizeProviderAddInputRunes([]rune(value))) } @@ -3332,6 +3307,3 @@ func (a *App) handleProviderAddResultMsg(msg providerAddResultMsg) { a.appendActivity("system", "Failed to refresh models", err.Error(), true) } } - - - diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 42e28cbc..9cc2d068 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -3551,3 +3552,414 @@ func TestSessionLogViewerPersistenceAndCap(t *testing.T) { 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 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 TestSessionLogHelpersAndSwitchBootstrap(t *testing.T) { + app, _ := newTestApp(t) + baseDir := app.configManager.BaseDir() + path := app.sessionLogEntriesPath("session-A") + if !strings.HasPrefix(path, baseDir) || !strings.Contains(path, "log-viewer") { + t.Fatalf("expected session log path under config dir, got %q", path) + } + if got := sanitizeSessionIDForFilename(" /a:b?c* "); got != "a_b_c" { + t.Fatalf("unexpected sanitized session id: %q", got) + } + + now := time.Unix(1_700_001_000, 0) + app.logEntries = []logEntry{{Timestamp: now, Level: "info", Source: "bootstrap", Message: "in-memory"}} + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + fileEntries := []logEntry{{Timestamp: now, Level: "info", Source: "file", Message: "persisted"}} + payload, _ := json.Marshal(fileEntries) + if err := os.WriteFile(path, payload, 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + 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") + } + + invalidPath := app.sessionLogEntriesPath("session-invalid") + if err := os.WriteFile(invalidPath, []byte("{invalid json"), 0o600); err != nil { + t.Fatalf("write invalid json failed: %v", err) + } + if got := app.readLogEntriesForSession("session-invalid"); got != nil { + t.Fatalf("expected invalid json log file 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() + + if got := app.sessionLogEntriesPath("___"); got != "" { + t.Fatalf("expected sanitized empty session id to yield empty path, got %q", got) + } +} + +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_test.go b/internal/tui/core/app/view_test.go index 897b01cb..e2ef3fef 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -624,3 +624,83 @@ func TestRenderLogViewerHonorsOffset(t *testing.T) { 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") + } +}