Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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}).
Expand Down
4 changes: 2 additions & 2 deletions internal/agent/cc_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/codex_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/live_feed_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
4 changes: 2 additions & 2 deletions internal/agent/opencode_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
}
}
Expand Down
98 changes: 70 additions & 28 deletions internal/agent/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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

Expand Down Expand Up @@ -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"))
Expand All @@ -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":
Expand All @@ -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":
Expand Down Expand Up @@ -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"`) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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. "+
Expand All @@ -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. "+
Expand All @@ -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.")
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
Loading
Loading