diff --git a/go.mod b/go.mod index 6f838b7..a3ff4e6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 github.com/chzyer/readline v1.5.1 github.com/coder/websocket v1.8.14 github.com/getsentry/sentry-go v0.44.1 @@ -20,7 +21,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a3cf2a7..851344e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -585,7 +585,7 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten a.Logger.Info("api", "request", map[string]interface{}{"model": model, "messages": len(a.History)}) if a.Cfg.Verbose { - fmt.Printf("[API] Streaming request: %d bytes, %d messages\n", len(data), len(a.History)) + fmt.Fprintf(term.Stderr(), "[API] Streaming request: %d bytes, %d messages\n", len(data), len(a.History)) } // Attribute the model on the Exposure Receipt entry for this prompt. @@ -670,7 +670,7 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten a.LastContextTokens = ev.Message.Usage.InputTokens a.Usage.Requests++ if a.Cfg.Verbose { - fmt.Printf("[SSE] message_start: input_tokens=%d\n", ev.Message.Usage.InputTokens) + fmt.Fprintf(term.Stderr(), "[SSE] message_start: input_tokens=%d\n", ev.Message.Usage.InputTokens) } } @@ -753,7 +753,7 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten stopReason = ev.Delta.StopReason a.Usage.OutputTokens += ev.Usage.OutputTokens if a.Cfg.Verbose { - fmt.Printf("[SSE] message_delta: stop=%s, output_tokens=%d\n", stopReason, ev.Usage.OutputTokens) + fmt.Fprintf(term.Stderr(), "[SSE] message_delta: stop=%s, output_tokens=%d\n", stopReason, ev.Usage.OutputTokens) } } @@ -861,7 +861,7 @@ func (a *Agent) executeToolCallsWithUI(toolCalls []api.ContentBlock, term *tui.T var results []api.ContentBlock for _, block := range toolCalls { a.Logger.Info("tool", block.Name, map[string]interface{}{"cost": ToolCost(block.Name)}) - output := ExecuteTool(block.Name, block.Input, a.Cfg.Context, ctx) + output := executeTool(block.Name, block.Input, a.Cfg.Context, ctx, term) // update_plan renders a dedicated checklist from its input rather than // the generic tool-result line (which would just echo {total,done}). diff --git a/internal/agent/cc_agent.go b/internal/agent/cc_agent.go index 0bf7e28..aba0370 100644 --- a/internal/agent/cc_agent.go +++ b/internal/agent/cc_agent.go @@ -431,7 +431,7 @@ func (a *CCAgent) Run(userMsg string, term *tui.Terminal) (string, error) { cmd := exec.CommandContext(ctx, a.claudeBin, args...) cmd.Stdin = strings.NewReader(safeUserMsg) - cmd.Stderr = os.Stderr // CC's own errors and status messages + cmd.Stderr = term.Stderr() // keep CC diagnostics inside the active viewport stdout, err := cmd.StdoutPipe() if err != nil { @@ -584,7 +584,7 @@ func (a *CCAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, ter if a.outputVerbose { term.PrintToolStart(displayName, block.Input) } else { - fmt.Println() + term.EndLine() } } } diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go index 1891d62..d8371ad 100644 --- a/internal/agent/codex_agent.go +++ b/internal/agent/codex_agent.go @@ -173,7 +173,7 @@ func (a *CodexAgent) Run(userMsg string, term *tui.Terminal) (string, error) { cmd := exec.CommandContext(ctx, a.codexBin, args...) cmd.Stdin = strings.NewReader("") - cmd.Stderr = os.Stderr + cmd.Stderr = term.Stderr() stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/internal/agent/live_feed_extra_test.go b/internal/agent/live_feed_extra_test.go index 285bb42..fa4fb0f 100644 --- a/internal/agent/live_feed_extra_test.go +++ b/internal/agent/live_feed_extra_test.go @@ -248,7 +248,7 @@ func TestRunTestWithProgressLiveFeedFastReturn(t *testing.T) { client := &api.APIClient{BaseURL: srv.URL, HTTP: srv.Client()} sctx := &api.SessionContext{LiveFeed: true, API: client} - result := runTestWithProgress(t.Context(), client, sctx, 42, true, "", "") + result := runTestWithProgress(t.Context(), client, sctx, 42, true, "", "", nil) // Must have returned early (contains client_note). if !strings.Contains(result, "client_note") { diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index c2c247a..6708116 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -164,7 +164,7 @@ func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) cmd := exec.CommandContext(ctx, a.openCodeBin, args...) cmd.Stdin = strings.NewReader("") - cmd.Stderr = os.Stderr + cmd.Stderr = term.Stderr() cmd.Env = append(os.Environ(), "OPENCODE_CONFIG="+configPath) for k, v := range OpenCodeProviderEnv(a.cfg) { cmd.Env = append(cmd.Env, k+"="+v) @@ -266,7 +266,7 @@ func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) a.mu.Unlock() term.PrintToolIcon(displayName) if !a.outputVerbose { - fmt.Println() + term.EndLine() } } } diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 0824a88..e7ca2af 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -790,6 +790,10 @@ func buildAllToolDefs() []api.ToolDef { // Uses the API client for QualityMax operations (no qmax CLI needed). // Falls back to qmax CLI if API client is not available. func ExecuteTool(name string, rawInput interface{}, sctx *api.SessionContext, ctx context.Context) string { + return executeTool(name, rawInput, sctx, ctx, nil) +} + +func executeTool(name string, rawInput interface{}, sctx *api.SessionContext, ctx context.Context, term *tui.Terminal) string { // update_plan is a local, side-effect-free planning surface — handle it // before any API/CLI dispatch. The rich terminal checklist is rendered by // the UI layer (executeToolCallsWithUI); here we just validate the steps and @@ -800,7 +804,7 @@ func ExecuteTool(name string, rawInput interface{}, sctx *api.SessionContext, ct // Use API client if available (standalone mode) if sctx.API != nil { - result := executeToolViaAPI(name, rawInput, sctx, ctx) + result := executeToolViaAPI(name, rawInput, sctx, ctx, term) if result != "" { return result } @@ -810,7 +814,7 @@ func ExecuteTool(name string, rawInput interface{}, sctx *api.SessionContext, ct } // executeToolViaAPI handles tool execution through the QualityMax REST API. -func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionContext, ctx context.Context) string { +func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionContext, ctx context.Context, term *tui.Terminal) string { input := parseInput(rawInput) client := sctx.API @@ -846,7 +850,7 @@ func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionConte return client.GenerateTestCode(ctx, intVal(input, "test_case_id", 0), boolVal(input, "force"), fw) case "run_test": - return runTestWithProgress(ctx, client, sctx, intVal(input, "script_id", 0), boolVal(input, "headless"), strVal(input, "browser"), strVal(input, "base_url")) + return runTestWithProgress(ctx, client, sctx, intVal(input, "script_id", 0), boolVal(input, "headless"), strVal(input, "browser"), strVal(input, "base_url"), term) case "run_native_test": return client.RunNativeTest(ctx, intVal(input, "script_id", 0), strVal(input, "base_url")) @@ -859,7 +863,7 @@ func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionConte case "check_test_status": out := client.CheckTestStatus(ctx, strVal(input, "execution_id")) - captureLiveURL(sctx, out) + captureLiveURLTo(sctx, out, term) return out case "start_crawl": @@ -868,7 +872,7 @@ func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionConte case "crawl_status": out := client.CrawlStatus(ctx, strVal(input, "crawl_id")) - captureLiveURL(sctx, out) + captureLiveURLTo(sctx, out, term) return out case "crawl_results": @@ -1008,7 +1012,7 @@ func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionConte // When sctx.LiveFeed is true the test runs in a QM Cloud Sandbox; status // responses are scanned for `live_browser_url` and the freshest one is // stored on sctx for the REPL to auto-launch /browserfeed against. -func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.SessionContext, scriptID int, headless bool, browser, baseURL string) string { +func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.SessionContext, scriptID int, headless bool, browser, baseURL string, term *tui.Terminal) string { // Start the test raw := client.RunTest(ctx, scriptID, headless, browser, baseURL, sctx != nil && sctx.LiveFeed) if strings.HasPrefix(raw, `{"error"`) { @@ -1039,10 +1043,31 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S "then call check_test_status ONCE to get the final result.", execID)) } - // Show browser animation + progress bar - fmt.Println() - tui.ShowBrowserAnimation(0) - progress := tui.NewProgressBar("Running test...", 30) + // Keep progress in the viewport's ephemeral activity line when it owns the + // terminal. The legacy cursor-rewriting animation remains available to + // non-interactive callers. + persistentProgress := term != nil && term.SetTurnActivity("Running test... 0%") + var progress *tui.ProgressBar + if !persistentProgress { + fmt.Println() + tui.ShowBrowserAnimation(0) + progress = tui.NewProgressBar("Running test...", 30) + } + clearProgress := func() { + if persistentProgress { + term.SetTurnActivity("") + return + } + tui.ClearBrowserAnimation() + } + finishProgress := func(success bool, message string) { + if persistentProgress { + term.SetTurnActivity("") + return + } + tui.ClearBrowserAnimation() + progress.Finish(success, message) + } // Poll until done. Bail out early if progress is stuck at the same value // for stuckLimit consecutive polls (~60 s) — that pattern means the @@ -1058,7 +1083,7 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S time.Sleep(2 * time.Second) statusRaw := client.CheckTestStatus(ctx, execID) - captureLiveURL(sctx, statusRaw) + captureLiveURLTo(sctx, statusRaw, term) var status map[string]interface{} if err := json.Unmarshal([]byte(statusRaw), &status); err != nil { continue @@ -1071,21 +1096,26 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S pct = int(p) } - // Animate browser - tui.ShowBrowserAnimation(frame) - frame++ - // Update progress if st == "running" || st == "queued" { if pct == 0 { pct = min(10+i*3, 90) // fake progress if backend doesn't report } - progress.Update(pct, msg) + if persistentProgress { + activity := fmt.Sprintf("Running test... %d%%", pct) + if msg != "" { + activity += " · " + tui.TruncateStr(msg, 40) + } + term.SetTurnActivity(activity) + } else { + tui.ShowBrowserAnimation(frame) + frame++ + progress.Update(pct, msg) + } } if st == "passed" || st == "failed" || st == "completed" { - tui.ClearBrowserAnimation() - progress.Finish(st == "passed", msg) + finishProgress(st == "passed", msg) return statusRaw } @@ -1094,7 +1124,7 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S // feed while the test is still running. The LLM must call // check_test_status once the user closes the feed to get the result. if sctx != nil && sctx.LiveFeed && sctx.LastLiveURL != "" { - tui.ClearBrowserAnimation() + clearProgress() return annotateWithClientNote(statusRaw, fmt.Sprintf("VNC feed is ready — returning early so the REPL opens the browser feed now. "+ "The test (execution_id: %s) is still running in the background. "+ @@ -1107,8 +1137,7 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S if pct == lastPct { stuckCount++ if stuckCount >= stuckLimit { - tui.ClearBrowserAnimation() - progress.Finish(false, fmt.Sprintf("stuck at %d%% for %ds — sandbox may be hung", pct, stuckCount*2)) + finishProgress(false, fmt.Sprintf("stuck at %d%% for %ds — sandbox may be hung", pct, stuckCount*2)) return annotateWithClientNote(statusRaw, fmt.Sprintf("Polling stopped: progress stuck at %d%% for %d seconds. "+ "Do NOT call check_test_status again — the run appears hung. "+ @@ -1120,10 +1149,9 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S } } - tui.ClearBrowserAnimation() - progress.Finish(false, "Timed out after 6 minutes") + finishProgress(false, "Timed out after 6 minutes") final := client.CheckTestStatus(ctx, execID) - captureLiveURL(sctx, final) + captureLiveURLTo(sctx, final, term) return annotateWithClientNote(final, "Polling stopped after 6 minutes. Do NOT call check_test_status again — the run timed out.") } @@ -1145,6 +1173,10 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S // the server is including, which directly maps to the relevant // fallback path on the server side. func captureLiveURL(sctx *api.SessionContext, raw string) { + captureLiveURLTo(sctx, raw, nil) +} + +func captureLiveURLTo(sctx *api.SessionContext, raw string, term *tui.Terminal) { if sctx == nil || raw == "" { return } @@ -1182,9 +1214,15 @@ func captureLiveURL(sctx *api.SessionContext, raw string) { if isE2B == "false" || isE2B == "absent" { color = "\033[33m" // yellow = warning, no sandbox } - fmt.Fprintf(os.Stderr, - "%s[live]\033[0m first status: is_e2b=%s, live_browser_url=%s (keys: %s)\n", - color, isE2B, liveURL, strings.Join(keys, ",")) + if term != nil { + fmt.Fprintf(term.Stderr(), + "%s[live]\033[0m first status: is_e2b=%s, live_browser_url=%s (keys: %s)\n", + color, isE2B, liveURL, strings.Join(keys, ",")) + } else { + fmt.Fprintf(os.Stderr, + "%s[live]\033[0m first status: is_e2b=%s, live_browser_url=%s (keys: %s)\n", + color, isE2B, liveURL, strings.Join(keys, ",")) + } sctx.SandboxModeLogged = true } } @@ -1201,7 +1239,11 @@ func captureLiveURL(sctx *api.SessionContext, raw string) { // when QMAX_LIVE_URL_FILE isn't set (standalone mode). sysutil.PersistLiveURLForParent(url) if sctx.LiveFeed && !sctx.LiveURLLogged { - fmt.Fprintln(os.Stderr, "\033[36m[live]\033[0m live_browser_url captured — auto-launch fires at end of turn") + if term != nil { + fmt.Fprintln(term.Stderr(), "\033[36m[live]\033[0m live_browser_url captured — auto-launch fires at end of turn") + } else { + fmt.Fprintln(os.Stderr, "\033[36m[live]\033[0m live_browser_url captured — auto-launch fires at end of turn") + } sctx.LiveURLLogged = true } } diff --git a/internal/repl/repl.go b/internal/repl/repl.go index abda178..f265b6b 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -204,6 +204,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin for { var input string + var turnImages []tui.ImageAttachment inputWasPasted := false // Drain the prompt queue before blocking on interactive input. @@ -1012,29 +1013,16 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin continue } term.PrintSystem(fmt.Sprintf("Screenshot captured (%s)", img.FileName)) - llmResult, err := ag.RunStreamingWithImages("Analyze this screenshot.", []tui.ImageAttachment{*img}, term) - if err != nil { - term.PrintError(err.Error()) - } - if llmResult != "" { - fmt.Println() - } - autoSave() - continue + input = "Analyze this screenshot." + turnImages = append(turnImages, *img) case input == "/paste": // Try image first, then text img, imgErr := tui.PasteImageFromClipboard() if imgErr == nil { term.PrintSystem(fmt.Sprintf("Pasted image from clipboard (%s)", img.FileName)) - llmResult, err := ag.RunStreamingWithImages("Analyze this pasted image.", []tui.ImageAttachment{*img}, term) - if err != nil { - term.PrintError(err.Error()) - } - if llmResult != "" { - fmt.Println() - } - autoSave() - continue + input = "Analyze this pasted image." + turnImages = append(turnImages, *img) + break } // Fall back to text paste text, textErr := tui.PasteTextFromClipboard() @@ -1061,7 +1049,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } // Detect image file paths dragged/pasted into input - cleanInput, images := tui.DetectAndLoadImages(input) + cleanInput, detectedImages := tui.DetectAndLoadImages(input) + images := append(turnImages, detectedImages...) + // /screenshot and image /paste have historically used the embedded + // multimodal backend even when a CLI backend is selected. Keep that + // behaviour; dragged image paths still follow the CLI's unsupported-note + // path as before. + runWithCLI := cliAgent != nil && len(turnImages) == 0 // Ensure a cloud session exists for this conversation (no-op after first call). startCloudSession() @@ -1075,20 +1069,15 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin c.SandboxFallbackSeen = false } - // Run through the LLM agent with streaming. - // Start the queue reader so the user can type the next prompt while - // the agent is working. It is stopped (and fully drained) before - // the next tui.ReadInput call so stdin is never shared between readers. - // Pressing Enter while typing cancels the running agent so the queued - // prompt is processed on the very next iteration. + // Run through the LLM agent with a persistent Bubble Tea viewport. It + // owns stdin and the bottom input/status panel for the whole turn, while + // Terminal routes streamed output into the region above it. var cancelCurrent func() - if cliAgent != nil { + if runWithCLI { cancelCurrent = cliAgent.Cancel } else { cancelCurrent = ag.CancelCurrent } - stopQueueReader := session.StartQueueReader(pq, term, cancelCurrent) - // CC mode: delegate entirely to Claude Code subprocess. This does not // require a QM Anthropic API key, but `claude --print` usage draws from // the user's Agent SDK credit starting 2026-06-15. @@ -1117,7 +1106,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // DialVNC — the stream needs a context that outlives the goroutine. watchCtx, watchCancel := context.WithCancel(context.Background()) defer watchCancel() // ensures cancel on any return/continue path - if cliAgent != nil { + if runWithCLI { go func() { // Do NOT defer watchCancel here — it is called by the main // goroutine after the agent turn ends. If we cancelled it here @@ -1157,64 +1146,55 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin watchCancel() } - if cliAgent != nil { - if len(images) > 0 { - term.PrintSystem("Note: image attachments are not supported in CLI backend mode.") - } - term.StartThinking() - llmResult, err = cliAgent.Run(cleanInput, term) - term.StopThinking() - if err == nil { - if stats, ok := cliAgent.(agent.TurnStatsProvider); ok { - if inputTokens, outputTokens, reported := stats.LastTurnStats(); reported { - ag.Usage.InputTokens += inputTokens - ag.Usage.OutputTokens += outputTokens - ag.Usage.Requests++ - lastContextTokens = inputTokens - ag.LastContextTokens = inputTokens + turnInput := tui.RunTurnViewport(term, term.Prompt(), inputStatus(), cancelCurrent, func() { + if runWithCLI { + if len(images) > 0 { + term.PrintSystem("Note: image attachments are not supported in CLI backend mode.") + } + term.StartThinking() + llmResult, err = cliAgent.Run(cleanInput, term) + term.StopThinking() + if err == nil { + if stats, ok := cliAgent.(agent.TurnStatsProvider); ok { + if inputTokens, outputTokens, reported := stats.LastTurnStats(); reported { + ag.Usage.InputTokens += inputTokens + ag.Usage.OutputTokens += outputTokens + ag.Usage.Requests++ + lastContextTokens = inputTokens + ag.LastContextTokens = inputTokens + } } + // Mirror the turn into ag.History so autoSave records it. + ag.History = append(ag.History, + api.Message{Role: "user", Content: cleanInput}, + api.Message{Role: "assistant", Content: llmResult}, + ) } - // Mirror the turn into ag.History so autoSave records it. - // agent.CCAgent/agent.CodexAgent manage their own subprocess state; qmax's - // history would otherwise stay empty and autoSave would no-op. - ag.History = append(ag.History, - api.Message{Role: "user", Content: cleanInput}, - api.Message{Role: "assistant", Content: llmResult}, - ) - } - } else if len(images) > 0 { - names := make([]string, len(images)) - for i, img := range images { - names[i] = img.FileName - } - term.PrintSystem(fmt.Sprintf("Attached %d image(s): %s", len(images), strings.Join(names, ", "))) - if cleanInput == "" { - cleanInput = "Analyze these images." - } - llmResult, err = ag.RunStreamingWithImages(cleanInput, images, term) - } else { - llmResult, err = ag.RunStreaming(input, term) - } + } else if len(images) > 0 { + names := make([]string, len(images)) + for i, img := range images { + names[i] = img.FileName + } + term.PrintSystem(fmt.Sprintf("Attached %d image(s): %s", len(images), strings.Join(names, ", "))) + if cleanInput == "" { + cleanInput = "Analyze these images." + } + llmResult, err = ag.RunStreamingWithImages(cleanInput, images, term) + } else { + llmResult, err = ag.RunStreaming(input, term) + } + }) lastTurnDur = time.Since(turnStarted) if err == nil { lastTask = cleanInput - if cliAgent == nil { + if !runWithCLI { lastContextTokens = ag.LastContextTokens } } - // Stop queue reader and wait for the goroutine to exit before we - // touch stdin again (either via the queue loop or tui.ReadInput). - // If the user was mid-typing when the agent finished — i.e. typed a - // partial line but didn't press Enter before the response came back — - // the queue reader returns that text. Push it onto the queue so it - // isn't silently lost (QUA-577). The user can /queue clear if it was - // stray input. - partial := strings.TrimSpace(stopQueueReader()) - if partial != "" { - pq.Push(partial) - fmt.Println() - term.PrintSystem(fmt.Sprintf("↩ partial input recovered → queued [%d]: %s (type /queue clear to discard)", pq.Len(), partial)) + if turnInput.Text != "" { + pq.Push(turnInput.Text) + term.PrintSystem(fmt.Sprintf("✓ queued [%d]: %s", pq.Len(), turnInput.Text)) } // If prompts were queued during this run, surface them. @@ -1228,7 +1208,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // output. Only structural metadata that helps diagnose without revealing // what the user was working on. backendTag := "api" - if cliAgent != nil { + if runWithCLI { backendTag = ag.Cfg.Context.Backend } sysutil.CaptureError(err, map[string]interface{}{ @@ -1260,7 +1240,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // serve --mcp` subprocess; the URL it stored sits on that // process's sctx, not ours. Prefer the URL the pre-connect goroutine // already drained; fall back to a final drain for non-cliAgent runs. - if cliAgent != nil { + if runWithCLI { if preConn.url != "" { ag.Cfg.Context.LastLiveURL = preConn.url ag.Cfg.Context.SandboxModeLogged = true diff --git a/internal/tui/input.go b/internal/tui/input.go index 3ded896..5b42152 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -423,12 +423,18 @@ var ( ) func (m inputModel) View() string { - // When done, show only the final input — no menu/box residue. + // Keep the submitted input panel in the terminal scrollback. Streaming starts + // immediately after Bubble Tea exits, so returning a bare prompt here used to + // make the agent stream visually collapse into the user's last input. if m.done { if m.result == "" { return "" } - return m.prompt + m.result + "\n" + w := m.width + if w <= 0 { + w = 80 + } + return m.renderInputBox(m.result, w) + "\n" } var b strings.Builder diff --git a/internal/tui/input_test.go b/internal/tui/input_test.go index 6794c92..02b4fe9 100644 --- a/internal/tui/input_test.go +++ b/internal/tui/input_test.go @@ -149,6 +149,20 @@ func TestInputFooterShowsOutputModeAndHotkeys(t *testing.T) { } } +func TestSubmittedInputKeepsPanelBoundaryForStream(t *testing.T) { + m := newInputModel("qmax > ", nil) + m.width = 80 + m.done = true + m.result = "check projects" + + view := m.View() + for _, want := range []string{"╭", "╯", "qmax > check projects"} { + if !strings.Contains(view, want) { + t.Fatalf("submitted input should retain panel boundary %q in %q", want, view) + } + } +} + func TestInputSeparatesPromptAndRendersSessionStatus(t *testing.T) { m := newInputModelWithOutputMode("qmax > ", nil, false) m.width = 100 diff --git a/internal/tui/term_image.go b/internal/tui/term_image.go index 1d53635..ec59a9a 100644 --- a/internal/tui/term_image.go +++ b/internal/tui/term_image.go @@ -106,12 +106,20 @@ func renderHalfBlock(img image.Image, maxWidth int) error { // RenderScreenshotCompact shows a framed screenshot label. func RenderScreenshotCompact(label string, url string) { + fmt.Print(FormatScreenshotCompact(label, url)) +} + +// FormatScreenshotCompact returns the compact screenshot frame so callers can +// send it through a persistent terminal renderer instead of writing directly. +func FormatScreenshotCompact(label string, url string) string { border := strings.Repeat("─", 44) - fmt.Printf(" ┌%s┐\n", border) - fmt.Printf(" │ 📸 %-42s│\n", TruncateStr(label, 42)) + var b strings.Builder + fmt.Fprintf(&b, " ┌%s┐\n", border) + fmt.Fprintf(&b, " │ 📸 %-42s│\n", TruncateStr(label, 42)) if url != "" { short := TruncateStr(url, 42) - fmt.Printf(" │ %-44s│\n", short) + fmt.Fprintf(&b, " │ %-44s│\n", short) } - fmt.Printf(" └%s┘\n", border) + fmt.Fprintf(&b, " └%s┘\n", border) + return b.String() } diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index ba12cf1..7c4e7d1 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/chzyer/readline" @@ -114,6 +115,10 @@ type spinner struct { func newSpinner(t *Terminal) *spinner { s := &spinner{stop: make(chan struct{}), term: t} + if p := t.activeTurnProgram(); p != nil { + p.Send(turnThinkingMsg(true)) + return s + } msg := spinnerMessages[rand.Intn(len(spinnerMessages))] s.wg.Add(1) go func() { @@ -144,6 +149,12 @@ func newSpinner(t *Terminal) *spinner { func (s *spinner) Stop() { s.done.Do(func() { close(s.stop) + if s.term != nil { + if p := s.term.activeTurnProgram(); p != nil { + p.Send(turnThinkingMsg(false)) + return + } + } s.wg.Wait() }) } @@ -158,6 +169,62 @@ type Terminal struct { thinking *spinner // active thinking spinner, if any userTyping atomic.Bool // true while StartQueueReader has the user typing toolStreak bool // last printed line was a tool call; keeps consecutive tools tight + turnMu sync.RWMutex + turnProgram *tea.Program // non-nil while a persistent turn viewport owns rendering +} + +func (t *Terminal) attachTurnProgram(p *tea.Program) { + t.turnMu.Lock() + t.turnProgram = p + t.turnMu.Unlock() +} + +func (t *Terminal) detachTurnProgram(p *tea.Program) { + t.turnMu.Lock() + if t.turnProgram == p { + t.turnProgram = nil + } + t.turnMu.Unlock() +} + +func (t *Terminal) activeTurnProgram() *tea.Program { + t.turnMu.RLock() + defer t.turnMu.RUnlock() + return t.turnProgram +} + +func (t *Terminal) emit(text string) { + if p := t.activeTurnProgram(); p != nil { + p.Send(turnOutputMsg(text)) + return + } + fmt.Print(text) +} + +func (t *Terminal) printf(format string, args ...interface{}) { + t.emit(fmt.Sprintf(format, args...)) +} + +// Write makes Terminal an io.Writer so subprocess diagnostics can be routed +// through the renderer that currently owns the terminal. +func (t *Terminal) Write(p []byte) (int, error) { + t.emit(string(p)) + return len(p), nil +} + +// EndLine completes an output line through whichever renderer currently owns +// the terminal. CLI backends use it instead of writing directly to stdout. +func (t *Terminal) EndLine() { t.emit("\n") } + +// SetTurnActivity updates an ephemeral activity line above the persistent +// input panel. It returns false when no turn viewport is active so callers can +// fall back to their legacy terminal animation. +func (t *Terminal) SetTurnActivity(text string) bool { + if p := t.activeTurnProgram(); p != nil { + p.Send(turnActivityMsg(text)) + return true + } + return false } // SetUserTyping tells the active spinner to pause (true) or resume (false) its @@ -170,7 +237,13 @@ func (t *Terminal) SetUserTyping(v bool) { // readline instance steers around the prompt. Callers in other packages need // this to emit verbose messages without corrupting the readline buffer. func (t *Terminal) Stderr() io.Writer { - if t == nil || t.rl == nil { + if t == nil { + return os.Stderr + } + if t.activeTurnProgram() != nil { + return t + } + if t.rl == nil { return os.Stderr } return t.rl.Stderr() @@ -373,13 +446,13 @@ func (t *Terminal) StreamText(text string) { t.toolStreak = false t.streamBuf.Reset() // Hide readline prompt during streaming to prevent input overlap - if t.rl != nil { + if t.activeTurnProgram() == nil && t.rl != nil { t.rl.SetPrompt("") t.rl.Refresh() } - fmt.Println() // newline before assistant response + t.emit("\n") // newline before assistant response } - fmt.Print(text) + t.emit(text) t.streamBuf.WriteString(text) } @@ -389,6 +462,13 @@ func (t *Terminal) FinishMarkdown(fullText string) { t.toolStreak = false if t.streaming { t.streaming = false + if t.activeTurnProgram() != nil { + t.streamBuf.Reset() + if !strings.HasSuffix(fullText, "\n") { + t.emit("\n") + } + return + } // If the text contains code blocks, re-render with glamour for highlighting if t.renderer != nil && strings.Contains(fullText, "```") { @@ -424,11 +504,11 @@ func (t *Terminal) PrintAssistant(text string) { if t.renderer != nil { rendered, err := t.renderer.Render(text) if err == nil { - fmt.Print(rendered) + t.emit(rendered) return } } - fmt.Println(text) + t.emit(text + "\n") } // PrintToolIcon shows a tool icon when a tool_use block starts streaming. @@ -438,7 +518,7 @@ func (t *Terminal) PrintToolIcon(name string) { t.StopThinking() // erase spinner before tool display if t.streaming { t.streaming = false - fmt.Println() + t.emit("\n") } icon := toolIcons[name] if icon == "" { @@ -446,19 +526,19 @@ func (t *Terminal) PrintToolIcon(name string) { } displayName := strings.ReplaceAll(name, "_", " ") if !t.toolStreak { - fmt.Println() + t.emit("\n") } t.toolStreak = true - fmt.Printf(" %s %s", icon, styleTool.Render(displayName)) + t.emit(fmt.Sprintf(" %s %s", icon, styleTool.Render(displayName))) } // PrintToolStart shows a tool invocation with its input summary. func (t *Terminal) PrintToolStart(name string, input interface{}) { summary := formatToolInput(input) if summary != "" { - fmt.Printf(" %s", styleToolDim.Render(summary)) + t.emit(fmt.Sprintf(" %s", styleToolDim.Render(summary))) } - fmt.Println() + t.emit("\n") } // PrintToolResult shows tool output with smart formatting for known result types. @@ -466,7 +546,7 @@ func (t *Terminal) PrintToolStart(name string, input interface{}) { func (t *Terminal) PrintToolResult(name string, output string) { defer t.StartThinking() if strings.HasPrefix(output, "{\"error\"") { - fmt.Printf(" %s %s\n", styleError.Render("✗ Error"), styleDim.Render(TruncateStr(output, 120))) + t.printf(" %s %s\n", styleError.Render("✗ Error"), styleDim.Render(TruncateStr(output, 120))) return } @@ -476,20 +556,20 @@ func (t *Terminal) PrintToolResult(name string, output string) { if err := json.Unmarshal([]byte(output), &data); err == nil { execID, _ := data["execution_id"].(string) status, _ := data["status"].(string) - fmt.Printf(" Execution: %s\n", styleDim.Render(execID)) + t.printf(" Execution: %s\n", styleDim.Render(execID)) switch status { case "passed": - fmt.Printf(" Status: %s\n", styleSuccess.Render("✓ PASSED")) + t.printf(" Status: %s\n", styleSuccess.Render("✓ PASSED")) case "failed": - fmt.Printf(" Status: %s\n", styleError.Render("✗ FAILED")) + t.printf(" Status: %s\n", styleError.Render("✗ FAILED")) default: - fmt.Printf(" Status: %s\n", styleSystem.Render(status)) + t.printf(" Status: %s\n", styleSystem.Render(status)) } if msg, ok := data["message"].(string); ok && msg != "" { - fmt.Printf(" Message: %s\n", styleDim.Render(TruncateStr(msg, 120))) + t.printf(" Message: %s\n", styleDim.Render(TruncateStr(msg, 120))) } if errs, ok := data["test_errors"].(string); ok && errs != "" { - fmt.Printf(" Errors: %s\n", styleError.Render(TruncateStr(errs, 300))) + t.printf(" Errors: %s\n", styleError.Render(TruncateStr(errs, 300))) } // Extract errors from console_logs if test_errors is empty if logs, ok := data["console_logs"].([]interface{}); ok && len(logs) > 0 { @@ -503,25 +583,25 @@ func (t *Terminal) PrintToolResult(name string, output string) { } } if len(errorLines) > 0 { - fmt.Printf(" Console errors:\n") + t.emit(" Console errors:\n") for _, line := range errorLines { - fmt.Printf(" %s\n", styleError.Render(TruncateStr(line, 120))) + t.printf(" %s\n", styleError.Render(TruncateStr(line, 120))) } } } if dur, ok := data["execution_time"].(float64); ok && dur > 0 { - fmt.Printf(" Duration: %.1fs\n", dur) + t.printf(" Duration: %.1fs\n", dur) } if screenshots, ok := data["screenshot_paths"].([]interface{}); ok && len(screenshots) > 0 { - fmt.Printf(" Screenshots: %d captured\n", len(screenshots)) + t.printf(" Screenshots: %d captured\n", len(screenshots)) for i, s := range screenshots { if url, ok := s.(string); ok && url != "" { - RenderScreenshotCompact(fmt.Sprintf("Screenshot %d", i+1), url) + t.emit(FormatScreenshotCompact(fmt.Sprintf("Screenshot %d", i+1), url)) } } } if video, ok := data["video_path"].(string); ok && video != "" { - fmt.Printf(" Video: %s\n", styleDim.Render(video)) + t.printf(" Video: %s\n", styleDim.Render(video)) } return } @@ -532,10 +612,10 @@ func (t *Terminal) PrintToolResult(name string, output string) { if lineCount <= 3 { for _, line := range lines { - fmt.Printf(" %s\n", styleDim.Render(TruncateStr(line, 140))) + t.printf(" %s\n", styleDim.Render(TruncateStr(line, 140))) } } else { - fmt.Printf(" %s\n", styleSuccess.Render(fmt.Sprintf("✓ %d lines of output", lineCount))) + t.printf(" %s\n", styleSuccess.Render(fmt.Sprintf("✓ %d lines of output", lineCount))) } } @@ -553,7 +633,7 @@ func (t *Terminal) PrintPlan(steps []PlanStep) { t.StopThinking() // erase spinner before plan display if t.streaming { t.streaming = false - fmt.Println() + t.emit("\n") } t.toolStreak = false defer t.StartThinking() @@ -569,7 +649,7 @@ func (t *Terminal) PrintPlan(steps []PlanStep) { if icon == "" { icon = "📋" } - fmt.Printf("\n %s %s %s\n", + t.printf("\n %s %s %s\n", icon, styleTool.Render("plan"), styleDim.Render(fmt.Sprintf("(%d/%d done)", done, len(steps)))) @@ -587,27 +667,27 @@ func (t *Terminal) PrintPlan(steps []PlanStep) { mark = styleDim.Render("◦") title = s.Title } - fmt.Printf(" %s %s\n", mark, title) + t.printf(" %s %s\n", mark, title) } } // PrintSystem prints a system message. func (t *Terminal) PrintSystem(msg string) { t.toolStreak = false - fmt.Printf(" %s %s\n", styleSystem.Render("●"), msg) + t.emit(fmt.Sprintf(" %s %s\n", styleSystem.Render("●"), msg)) } // PrintError prints an error message. func (t *Terminal) PrintError(msg string) { t.toolStreak = false - fmt.Printf(" %s\n", styleError.Render("✗ "+msg)) + t.emit(fmt.Sprintf(" %s\n", styleError.Render("✗ "+msg))) } // PrintTokenUsage shows token usage in dim text after a response. func (t *Terminal) PrintTokenUsage(usage api.TokenUsage) { - fmt.Printf("\n%s\n", styleUsage.Render( + t.emit(fmt.Sprintf("\n%s\n", styleUsage.Render( fmt.Sprintf(" tokens: %d in / %d out (session: %d total, %d requests)", - usage.InputTokens, usage.OutputTokens, usage.TotalTokens(), usage.Requests))) + usage.InputTokens, usage.OutputTokens, usage.TotalTokens(), usage.Requests)))) } // PrintCostSummary shows detailed cost info for /cost command. diff --git a/internal/tui/turn_viewport.go b/internal/tui/turn_viewport.go new file mode 100644 index 0000000..3c18f1c --- /dev/null +++ b/internal/tui/turn_viewport.go @@ -0,0 +1,373 @@ +package tui + +import ( + "fmt" + "strings" + "time" + "unicode" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +// TurnInputResult is input collected while an agent turn is running. +// A submitted line is queued by the REPL and also interrupts the active turn. +type TurnInputResult struct { + Text string + Pasted bool + Canceled bool +} + +type turnOutputMsg string +type turnThinkingMsg bool +type turnActivityMsg string +type turnDoneMsg struct{} +type turnTickMsg time.Time + +const ( + maxLiveOutput = 64 * 1024 + keptLiveOutput = 48 * 1024 + maxStoredTurnOutput = 4 * 1024 * 1024 + keptTurnOutput = 3 * 1024 * 1024 +) + +const turnOutputTruncatedNotice = "\n … earlier turn output omitted to keep memory bounded …\n" + +type turnViewportCache struct { + revision uint64 + width int + wrapped string + lines []string + wraps int +} + +type turnViewportModel struct { + prompt string + status *StatusInfo + output *strings.Builder + live *strings.Builder + revision uint64 + cache *turnViewportCache + text []rune + cursor int + width int + height int + thinking bool + activity string + frame int + done bool + result TurnInputResult + cancelFn func() +} + +func newTurnViewportModel(prompt string, status *StatusInfo, cancelFn func()) turnViewportModel { + return turnViewportModel{ + prompt: prompt, + status: status, + output: &strings.Builder{}, + live: &strings.Builder{}, + cache: &turnViewportCache{}, + width: 80, + height: 24, + cancelFn: cancelFn, + } +} + +func (m turnViewportModel) Init() tea.Cmd { return turnViewportTick() } + +func turnViewportTick() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return turnTickMsg(t) }) +} + +func (m turnViewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case turnOutputMsg: + m.appendOutput(string(msg)) + return m, nil + case turnThinkingMsg: + m.thinking = bool(msg) + return m, nil + case turnActivityMsg: + m.activity = string(msg) + return m, nil + case turnDoneMsg: + // Preserve text that was still being composed when the backend + // completed. Do not overwrite a prompt already submitted by Enter if the + // backend's cancellation races with the queued turnDoneMsg. + if m.result.Text == "" { + m.result.Text = strings.TrimSpace(string(m.text)) + } + m.done = true + return m, tea.Quit + case turnTickMsg: + m.frame++ + return m, turnViewportTick() + case tea.KeyMsg: + return m.updateKey(msg) + } + return m, nil +} + +func (m *turnViewportModel) appendOutput(text string) { + if m.output == nil { + m.output = &strings.Builder{} + } + if m.live == nil { + m.live = &strings.Builder{} + } + m.output.WriteString(text) + m.live.WriteString(text) + m.revision++ + + // Retain enough output to restore useful scrollback after the viewport + // exits, but place a hard ceiling on unusually verbose CLI turns. Shrinking + // to a lower watermark avoids repeatedly copying on every subsequent token. + if m.output.Len() > maxStoredTurnOutput { + kept := safeOutputSuffix(m.output.String(), keptTurnOutput) + next := &strings.Builder{} + next.Grow(len(turnOutputTruncatedNotice) + len(kept)) + next.WriteString(turnOutputTruncatedNotice) + next.WriteString(kept) + m.output = next + } + + // Live redraws retain a much smaller suffix than final scrollback. Keeping + // this as a separate buffer bounds wrapping work even for a huge line with + // no newline, where a byte offset into the full ANSI stream would be unsafe. + if m.live.Len() > maxLiveOutput { + kept := safeOutputSuffix(m.live.String(), keptLiveOutput) + next := &strings.Builder{} + next.Grow(len(kept)) + next.WriteString(kept) + m.live = next + } +} + +func safeOutputSuffix(out string, keep int) string { + start := len(out) - keep + if start <= 0 { + return out + } + if newline := strings.IndexByte(out[start:], '\n'); newline >= 0 { + return out[start+newline+1:] + } + + // A very long line has no safe textual boundary. Strip styling before + // slicing so an ANSI escape sequence can never be cut in half. + plain := ansi.Strip(out) + start = len(plain) - keep + if start < 0 { + start = 0 + } + for start < len(plain) && !utf8.RuneStart(plain[start]) { + start++ + } + return plain[start:] +} + +func (m turnViewportModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + text := strings.TrimSpace(string(m.text)) + if text == "" { + return m, nil + } + m.result.Text = text + m.result.Canceled = true + m.done = true + return m, tea.Sequence(func() tea.Msg { + if m.cancelFn != nil { + m.cancelFn() + } + return nil + }, tea.Quit) + case tea.KeyCtrlC: + m.result.Canceled = true + m.done = true + return m, tea.Sequence(func() tea.Msg { + if m.cancelFn != nil { + m.cancelFn() + } + return nil + }, tea.Quit) + case tea.KeyBackspace: + if m.cursor > 0 { + m.text = append(m.text[:m.cursor-1], m.text[m.cursor:]...) + m.cursor-- + } + case tea.KeyDelete: + if m.cursor < len(m.text) { + m.text = append(m.text[:m.cursor], m.text[m.cursor+1:]...) + } + case tea.KeyLeft: + if msg.Alt { + m.cursor = previousWordBoundary(m.text, m.cursor) + } else if m.cursor > 0 { + m.cursor-- + } + case tea.KeyRight: + if msg.Alt { + m.cursor = nextWordBoundary(m.text, m.cursor) + } else if m.cursor < len(m.text) { + m.cursor++ + } + case tea.KeyCtrlLeft: + m.cursor = previousWordBoundary(m.text, m.cursor) + case tea.KeyCtrlRight: + m.cursor = nextWordBoundary(m.text, m.cursor) + case tea.KeyCtrlA: + m.cursor = 0 + case tea.KeyCtrlE: + m.cursor = len(m.text) + case tea.KeyCtrlW: + end := m.cursor + m.cursor = previousWordBoundary(m.text, m.cursor) + m.text = append(m.text[:m.cursor], m.text[end:]...) + case tea.KeyCtrlU: + m.text = nil + m.cursor = 0 + case tea.KeyCtrlK: + m.text = m.text[:m.cursor] + case tea.KeyHome: + m.cursor = 0 + case tea.KeyEnd: + m.cursor = len(m.text) + case tea.KeyRunes: + if msg.Alt && len(msg.Runes) == 1 { + switch msg.Runes[0] { + case 'b', 'B': + m.cursor = previousWordBoundary(m.text, m.cursor) + return m, nil + case 'f', 'F': + m.cursor = nextWordBoundary(m.text, m.cursor) + return m, nil + } + } + for _, r := range msg.Runes { + if unicode.IsControl(r) { + continue + } + m.text = append(m.text, 0) + copy(m.text[m.cursor+1:], m.text[m.cursor:]) + m.text[m.cursor] = r + m.cursor++ + } + m.result.Pasted = m.result.Pasted || msg.Paste + } + return m, nil +} + +func (m turnViewportModel) View() string { + if m.done { + return "" + } + + w := m.width + if w <= 0 { + w = 80 + } + input := inputModel{prompt: m.prompt, width: w, status: m.status} + display := append([]rune(nil), m.text[:m.cursor]...) + display = append(display, '█') + display = append(display, m.text[m.cursor:]...) + + var b strings.Builder + b.WriteString(m.visibleOutput()) + if b.Len() > 0 && !strings.HasSuffix(b.String(), "\n") { + b.WriteString("\n") + } + if m.activity != "" { + b.WriteString(fmt.Sprintf(" %s%s%s\n", ColorDim, m.activity, ColorReset)) + } else if m.thinking { + frame := spinnerFrames[m.frame%len(spinnerFrames)] + b.WriteString(fmt.Sprintf(" %s%s agent working...%s\n", ColorDim, frame, ColorReset)) + } + b.WriteString(input.renderInputBox(string(display), w)) + b.WriteString("\n") + b.WriteString(menuHintStyle.Render(" Enter queue + interrupt • Ctrl+C cancel turn • type while the agent works")) + if status := input.renderStatus(w); status != "" { + b.WriteString("\n") + b.WriteString(status) + } + return b.String() +} + +func (m turnViewportModel) visibleOutput() string { + if m.live == nil { + return "" + } + out := m.live.String() + width := m.width + if width <= 0 { + width = 80 + } + + var lines []string + if m.cache != nil && + m.cache.revision == m.revision && + m.cache.width == width && + m.cache.lines != nil { + lines = m.cache.lines + } else { + // Bubble Tea truncates over-wide physical lines. Wrap them first so a + // streamed paragraph remains readable while the persistent panel is active. + wrapped := ansi.Wrap(out, width, "") + lines = strings.Split(wrapped, "\n") + if m.cache != nil { + m.cache.revision = m.revision + m.cache.width = width + m.cache.wrapped = wrapped + m.cache.lines = lines + m.cache.wraps++ + } + } + if m.height <= 0 { + return strings.Join(lines, "\n") + } + // Reserve the bordered input, hint, metrics, bottom bar, and spinner. + maxLines := m.height - 8 + if maxLines < 3 { + maxLines = 3 + } + if len(lines) <= maxLines { + if m.cache != nil && m.cache.lines != nil { + return m.cache.wrapped + } + return strings.Join(lines, "\n") + } + return strings.Join(lines[len(lines)-maxLines:], "\n") +} + +// RunTurnViewport keeps a Bubble Tea-owned input/status region active while +// run emits streaming output through Terminal. It returns only after run exits. +func RunTurnViewport(term *Terminal, prompt string, status *StatusInfo, cancelFn func(), run func()) TurnInputResult { + m := newTurnViewportModel(prompt, status, cancelFn) + p := tea.NewProgram(m) + term.attachTurnProgram(p) + done := make(chan struct{}) + go func() { + defer close(done) + run() + p.Send(turnDoneMsg{}) + }() + result, err := p.Run() + <-done + // Keep the terminal attached until the canceled backend has actually + // stopped. Any late subprocess output is then discarded by the completed + // Bubble Tea program instead of escaping below the former input panel. + term.detachTurnProgram(p) + final, ok := result.(turnViewportModel) + if ok && final.output != nil { + // Bubble Tea only owns the live viewport. Re-emit the retained turn output + // once after it exits so normal terminal scrollback remains useful. + term.emit(final.output.String()) + } + if err != nil || !ok { + return TurnInputResult{} + } + return final.result +} diff --git a/internal/tui/turn_viewport_test.go b/internal/tui/turn_viewport_test.go new file mode 100644 index 0000000..0035559 --- /dev/null +++ b/internal/tui/turn_viewport_test.go @@ -0,0 +1,198 @@ +package tui + +import ( + "strings" + "testing" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" +) + +func updateTurnViewport(t *testing.T, m turnViewportModel, msg tea.Msg) (turnViewportModel, tea.Cmd) { + t.Helper() + updated, cmd := m.Update(msg) + next, ok := updated.(turnViewportModel) + if !ok { + t.Fatalf("Update returned %T, want turnViewportModel", updated) + } + return next, cmd +} + +func TestTurnViewportKeepsOutputAboveInputBoundary(t *testing.T) { + m := newTurnViewportModel("qmax > ", &StatusInfo{Backend: "cc", Task: "keep the input visible"}, nil) + m.width = 90 + m.height = 24 + m, _ = updateTurnViewport(t, m, turnOutputMsg("streamed response")) + + view := m.View() + for _, want := range []string{ + "streamed response", + "╭", "╮", "╰", "╯", + "qmax > █", + "Enter queue + interrupt", + "task: keep the input visible", + } { + if !strings.Contains(view, want) { + t.Fatalf("active viewport missing %q in %q", want, view) + } + } +} + +func TestTurnViewportAcceptsMultipleOutputMessages(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m, _ = updateTurnViewport(t, m, turnOutputMsg("first ")) + m, _ = updateTurnViewport(t, m, turnOutputMsg("second")) + + if got, want := m.output.String(), "first second"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestTurnViewportActivityReplacesSpinnerWithoutTouchingInput(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m, _ = updateTurnViewport(t, m, turnThinkingMsg(true)) + m, _ = updateTurnViewport(t, m, turnActivityMsg("Running test... 40%")) + + view := m.View() + if !strings.Contains(view, "Running test... 40%") { + t.Fatalf("activity missing from viewport: %q", view) + } + if strings.Contains(view, "agent working") { + t.Fatalf("spinner should yield to specific activity: %q", view) + } + if !strings.Contains(view, "qmax > █") { + t.Fatalf("activity update displaced the input panel: %q", view) + } +} + +func TestTurnViewportSubmitReturnsQueuedInput(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m, _ = updateTurnViewport(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("next prompt"), Paste: true}) + m, cmd := updateTurnViewport(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + if cmd == nil { + t.Fatal("Enter should interrupt and quit the active viewport") + } + if got, want := m.result.Text, "next prompt"; got != want { + t.Fatalf("queued text = %q, want %q", got, want) + } + if !m.result.Canceled || !m.result.Pasted { + t.Fatalf("result = %#v, want canceled pasted input", m.result) + } +} + +func TestTurnViewportEditingMatchesMainInput(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m.text = []rune("alpha beta") + m.cursor = len(m.text) + m, _ = updateTurnViewport(t, m, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + m, _ = updateTurnViewport(t, m, tea.KeyMsg{Type: tea.KeyDelete}) + + if got, want := string(m.text), "alpha eta"; got != want { + t.Fatalf("edited text = %q, want %q", got, want) + } +} + +func TestTurnViewportPreservesDraftWhenTurnFinishes(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m, _ = updateTurnViewport(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("follow up")}) + m, cmd := updateTurnViewport(t, m, turnDoneMsg{}) + + if cmd == nil { + t.Fatal("turn completion should quit the active viewport") + } + if got, want := m.result.Text, "follow up"; got != want { + t.Fatalf("recovered draft = %q, want %q", got, want) + } +} + +func TestTurnViewportDoneDoesNotOverwriteSubmittedInput(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m.text = []rune("submitted prompt") + m.cursor = len(m.text) + m, _ = updateTurnViewport(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + // Simulate another input event winning the race before the backend's done + // message is delivered. The submitted result must remain authoritative. + m.text = nil + m, _ = updateTurnViewport(t, m, turnDoneMsg{}) + + if got, want := m.result.Text, "submitted prompt"; got != want { + t.Fatalf("completed turn replaced submitted input: got %q, want %q", got, want) + } +} + +func TestTurnViewportOnlyShowsOutputThatFitsAbovePanel(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m.height = 12 // four output lines after reserving the persistent panel + m.appendOutput("one\ntwo\nthree\nfour\nfive\nsix") + + visible := m.visibleOutput() + if strings.Contains(visible, "one") || strings.Contains(visible, "two") { + t.Fatalf("old output should scroll out of the viewport: %q", visible) + } + for _, want := range []string{"three", "four", "five", "six"} { + if !strings.Contains(visible, want) { + t.Fatalf("visible output missing %q in %q", want, visible) + } + } +} + +func TestTurnViewportWrapsLongStreamingLines(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m.width = 20 + m.height = 20 + m.appendOutput("abcdefghijklmnopqrstuvwxyz") + + visible := m.visibleOutput() + if !strings.Contains(visible, "\n") || !strings.Contains(visible, "uvwxyz") { + t.Fatalf("long stream line was not wrapped into visible output: %q", visible) + } +} + +func TestTurnViewportCachesWrappedOutputAcrossTicks(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + m.appendOutput("a streaming line that needs wrapping") + m.width = 12 + + _ = m.visibleOutput() + _ = m.visibleOutput() + if got, want := m.cache.wraps, 1; got != want { + t.Fatalf("unchanged output wrapped %d times, want %d", got, want) + } + + m, _ = updateTurnViewport(t, m, turnTickMsg{}) + _ = m.visibleOutput() + if got, want := m.cache.wraps, 1; got != want { + t.Fatalf("timer tick re-wrapped unchanged output %d times, want %d", got, want) + } + + m.appendOutput(" with more text") + _ = m.visibleOutput() + if got, want := m.cache.wraps, 2; got != want { + t.Fatalf("changed output wrapped %d times, want %d", got, want) + } +} + +func TestTurnViewportBoundsStoredAndLiveOutput(t *testing.T) { + m := newTurnViewportModel("qmax > ", nil, nil) + // No newline forces the ANSI/UTF-8-safe fallback rather than the normal + // line-boundary trim. + huge := "\x1b[31m" + strings.Repeat("é", maxStoredTurnOutput/2+1024) + m.appendOutput(huge) + + if m.output.Len() > maxStoredTurnOutput { + t.Fatalf("stored output = %d bytes, want <= %d", m.output.Len(), maxStoredTurnOutput) + } + if m.live.Len() > maxLiveOutput { + t.Fatalf("live output = %d bytes, want <= %d", m.live.Len(), maxLiveOutput) + } + if !strings.Contains(m.output.String(), turnOutputTruncatedNotice) { + t.Fatal("bounded scrollback did not explain that earlier output was omitted") + } + if !utf8.ValidString(m.output.String()) || !utf8.ValidString(m.live.String()) { + t.Fatal("output bounding split a UTF-8 rune") + } + if strings.Contains(m.live.String(), "\x1b[") { + t.Fatal("long-line live suffix retained a partial or active ANSI sequence") + } +}