Skip to content

Commit 56262b4

Browse files
torrmalclaude
andcommitted
Make interactive mode a real session with a visible work trail
User-reported: sending a task felt like it fell out of the CLI rather than being part of one experience — the prompt took over the screen, the run ended, and the program exited after a single task. - The prompt no longer switches to the terminal's alternate screen or clears the display. It draws inline and redraws only its own rows (wrapping accounted for), so everything above it stays on screen and in scrollback. - Running `yolocoder` with no task now stays in a session: prompt, work, result, prompt again. `/exit`, `/quit`, or Ctrl+C leaves. Passing a task as an argument still runs once and exits. - Progress is now a permanent trail instead of one self-overwriting line: the files read, searches run, plan, patch, apply, and test result each print and stay. ui.Session animates the current activity while Log writes lines above it; agent reports through a Progress interface. - A self-update now says it happened before relaunching, so the version change isn't silent. Verified through a pty: multi-turn session, conversational reply, coding task trail, Shift+Enter multiline, /exit, Ctrl+C, and one-shot mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8fd21b2 commit 56262b4

11 files changed

Lines changed: 419 additions & 136 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@ repository is required: YoloCoder maps and patches the folder directly. If
1111
the folder already has its own `.git` (not an ancestor directory's), it's
1212
used opportunistically for a `.gitignore`-aware map and faster patch checks.
1313

14-
Running `yolocoder` without a task opens a multiline terminal editor showing
15-
the current folder. Paste multiline instructions normally, use arrow keys to
16-
reposition the cursor, press Enter to submit, and press Shift+Enter for a new
14+
Passing a task runs it once and exits. Running `yolocoder` with no task starts
15+
an interactive session instead: it prompts, works, reports what it did, then
16+
prompts again, all in one continuous transcript. Leave with `/exit` or Ctrl+C.
17+
18+
At the prompt, paste multiline instructions normally, use arrow keys to
19+
reposition the cursor, press Enter to send, and press Shift+Enter for a new
1720
line (falls back to Enter-only in terminals that don't report Shift+Enter
1821
separately).
1922

23+
While it works, YoloCoder prints what it is actually doing — the files it
24+
reads, the searches it runs, the plan, the patch, and the test result — so
25+
the finished session leaves a readable trail rather than a single status
26+
line that overwrites itself.
27+
2028
## Install
2129

2230
macOS and Linux:

‎cmd/yolocoder/main.go‎

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"strings"
89

910
"github.com/mindsdb/yolocoder/internal/app"
11+
"github.com/mindsdb/yolocoder/internal/config"
12+
"github.com/mindsdb/yolocoder/internal/terminal"
1013
"github.com/mindsdb/yolocoder/internal/ui"
1114
"github.com/mindsdb/yolocoder/internal/update"
1215
"github.com/mindsdb/yolocoder/internal/version"
@@ -43,6 +46,7 @@ func main() {
4346
// loaded until the next separate invocation. If it can't even
4447
// start the new binary, fall through and keep running on the
4548
// old one rather than aborting.
49+
fmt.Println("[^_^] Updated to a new build, restarting...")
4650
if err := update.Relaunch(); err != nil {
4751
fmt.Fprintln(os.Stderr, "restart after update:", err)
4852
}
@@ -67,36 +71,69 @@ func main() {
6771
os.Exit(app.RunModel(args[1:]))
6872
}
6973

70-
fmt.Printf("YoloCoder %s\n", version.Display())
71-
workingDirectory, err := os.Getwd()
72-
if err != nil {
73-
fmt.Fprintf(os.Stderr, "find current folder: %v\n", err)
74-
os.Exit(1)
75-
}
76-
fmt.Printf("Folder: %s\n", workingDirectory)
7774
fromEnvironment := false
7875
if len(args) > 0 && args[0] == "--llm-from-env-vars" {
7976
fromEnvironment = true
8077
args = args[1:]
8178
}
82-
task := strings.TrimSpace(strings.Join(args, " "))
83-
task, provider, err := app.PrepareTask(task, fromEnvironment)
79+
80+
fmt.Printf("\x1b[36m[^_^] YoloCoder %s\x1b[0m \x1b[2m%s\x1b[0m\n", version.Display(), app.Folder())
81+
82+
provider, err := app.Provider(fromEnvironment)
8483
if err != nil {
8584
fmt.Fprintln(os.Stderr, err)
8685
os.Exit(1)
8786
}
88-
var reply string
89-
var runErr error
90-
ui.WithRobot(os.Stdout, "Thinking...", func(status ui.RobotStatus) {
91-
reply, runErr = app.RunTask(context.Background(), task, provider, status)
92-
})
93-
if runErr != nil {
94-
fmt.Fprintln(os.Stderr, runErr)
95-
os.Exit(1)
87+
88+
// A task on the command line is a one-shot run; without one, stay in
89+
// an interactive session so follow-up tasks keep the same context on
90+
// screen instead of ending after a single change.
91+
if task := strings.TrimSpace(strings.Join(args, " ")); task != "" {
92+
if err := runTask(task, provider); err != nil {
93+
fmt.Fprintln(os.Stderr, err)
94+
os.Exit(1)
95+
}
96+
return
97+
}
98+
99+
fmt.Printf("\x1b[2mEnter: send • Shift+Enter: new line • Ctrl+C or /exit: quit\x1b[0m\n\n")
100+
for {
101+
task, err := app.PromptTask()
102+
if err != nil {
103+
if errors.Is(err, terminal.ErrEditorCancelled) {
104+
fmt.Println("[^_^] Bye.")
105+
return
106+
}
107+
fmt.Fprintln(os.Stderr, err)
108+
os.Exit(1)
109+
}
110+
switch task {
111+
case "":
112+
continue
113+
case "/exit", "/quit":
114+
fmt.Println("[^_^] Bye.")
115+
return
116+
}
117+
if err := runTask(task, provider); err != nil {
118+
fmt.Fprintln(os.Stderr, err)
119+
}
120+
fmt.Println()
121+
}
122+
}
123+
124+
// runTask runs one task, reporting progress as it goes and printing the
125+
// model's reply at the end.
126+
func runTask(task string, provider config.LLM) error {
127+
session := ui.NewSession(os.Stdout)
128+
session.Start("Thinking...")
129+
reply, err := app.RunTask(context.Background(), task, provider, session)
130+
session.Stop()
131+
if err != nil {
132+
return err
96133
}
97-
if reply != "" {
98-
fmt.Printf("[*_*] %s\n", reply)
99-
} else {
100-
fmt.Println("[*_*] Done.")
134+
if reply == "" {
135+
reply = "Done."
101136
}
137+
fmt.Printf("[*_*] %s\n", reply)
138+
return nil
102139
}

‎internal/agent/agent.go‎

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strconv"
78
"strings"
89
"time"
910

@@ -66,6 +67,14 @@ func jsonSpan(text string) string {
6667
return ""
6768
}
6869

70+
// Progress reports what the agent is doing. Status replaces a transient
71+
// activity line; Log writes a permanent line, leaving the user a readable
72+
// trail of the work rather than a single message that overwrites itself.
73+
type Progress interface {
74+
Status(string)
75+
Log(string)
76+
}
77+
6978
type Runner struct {
7079
client *Client
7180
repository *repo.Repository
@@ -78,8 +87,8 @@ func NewRunner(client *Client, repository *repo.Repository) *Runner {
7887
// Run routes the message first: a plain conversational message gets the
7988
// model's direct reply with the repository never touched, and only an
8089
// actual coding task goes through the map/plan/patch/test loop.
81-
func (runner *Runner) Run(ctx context.Context, task string, progress func(string)) (string, error) {
82-
progress("Reading your message...")
90+
func (runner *Runner) Run(ctx context.Context, task string, progress Progress) (string, error) {
91+
progress.Status("Reading your message...")
8392
decision, err := runner.route(ctx, task)
8493
if err != nil {
8594
return "", err
@@ -88,50 +97,65 @@ func (runner *Runner) Run(ctx context.Context, task string, progress func(string
8897
return decision.Reply, nil
8998
}
9099

100+
progress.Status("Mapping the folder...")
91101
repoMap, err := runner.repository.Map()
92102
if err != nil {
93103
return "", err
94104
}
95-
progress("Finding the right code...")
96-
plan, contextText, err := runner.plan(ctx, task, repoMap)
105+
progress.Log(fmt.Sprintf(" mapped %d files", countMapped(repoMap)))
106+
107+
progress.Status("Finding the right code...")
108+
plan, contextText, err := runner.plan(ctx, task, repoMap, progress)
97109
if err != nil {
98110
return "", err
99111
}
112+
progress.Log(" plan: " + plan.Summary)
113+
for _, path := range plan.FilesToModify {
114+
progress.Log(" will edit " + path)
115+
}
100116
plannedContext, err := runner.readPlanFiles(plan)
101117
if err != nil {
102118
return "", err
103119
}
104120
contextText += "\nPLANNED FILES:\n" + plannedContext
105-
progress("Making the change...")
106-
patch, err := runner.patch(ctx, task, repoMap, plan, contextText, "")
107-
if err != nil {
108-
return "", err
109-
}
110121

111122
var evidence string
123+
var patch Patch
112124
for attempt := 0; attempt < 3; attempt++ {
113125
if attempt > 0 {
114-
progress("Repairing from new evidence...")
126+
progress.Status("Repairing from new evidence...")
115127
contextText, err = runner.readPlanFiles(plan)
116128
if err != nil {
117129
return "", err
118130
}
119-
patch, err = runner.patch(ctx, task, repoMap, plan, contextText, evidence)
120-
if err != nil {
121-
return "", err
122-
}
131+
} else {
132+
progress.Status("Writing the change...")
123133
}
124-
progress("Applying the patch...")
134+
patch, err = runner.patch(ctx, task, repoMap, plan, contextText, evidence)
135+
if err != nil {
136+
return "", err
137+
}
138+
progress.Log(" patch: " + patch.Summary)
139+
140+
progress.Status("Applying the patch...")
125141
if err := runner.repository.Apply(patch.Diff); err != nil {
142+
progress.Log(" patch did not apply, retrying")
126143
evidence = "The patch did not apply:\n" + err.Error()
127144
continue
128145
}
129-
progress("Testing the change...")
146+
progress.Log(" applied the patch")
147+
148+
progress.Status("Testing the change...")
130149
testResult := RunTests(ctx, runner.repository.Root)
131150
if testResult.Passed {
132-
progress("Done.")
151+
if testResult.Skipped {
152+
progress.Log(" no test command detected")
153+
} else {
154+
progress.Log(" tests passed")
155+
}
133156
return patch.Summary, nil
134157
}
158+
progress.Log(" tests failed, retrying")
135159
evidence = "The patch applied, but tests failed. Produce an incremental diff against the current repository.\n" + testResult.Output
136160
}
137161
return "", fmt.Errorf("could not complete the task after repair attempts:\n%s", evidence)
@@ -165,7 +189,7 @@ func (runner *Runner) route(ctx context.Context, task string) (routeDecision, er
165189
// response state, and one that doesn't will reject a function_call_output
166190
// referencing a call_id it never stored, so the transcript is tracked here
167191
// instead.
168-
func (runner *Runner) plan(ctx context.Context, task, repoMap string) (Plan, string, error) {
192+
func (runner *Runner) plan(ctx context.Context, task, repoMap string, progress Progress) (Plan, string, error) {
169193
taskText := fmt.Sprintf("TASK:\n%s\n\nREPOSITORY MAP:\n%s", task, repoMap)
170194
var transcript []any
171195
var collected strings.Builder
@@ -203,6 +227,7 @@ func (runner *Runner) plan(ctx context.Context, task, repoMap string) (Plan, str
203227
transcript = []any{inputMessage{Role: "user", Content: taskText}}
204228
}
205229
for _, call := range calls {
230+
progress.Log(" " + describeCall(call))
206231
output := runner.runTool(ctx, call)
207232
fmt.Fprintf(&collected, "\nTOOL %s:\n%s\n", call.Name, output)
208233
transcript = append(transcript, call)
@@ -242,6 +267,34 @@ func (runner *Runner) patch(ctx context.Context, task, repoMap string, plan Plan
242267
return patch, nil
243268
}
244269

270+
// describeCall renders a tool call as a short line for the progress log,
271+
// so the user can see which files the model is actually looking at.
272+
func describeCall(call responseItem) string {
273+
var arguments toolArguments
274+
if err := json.Unmarshal([]byte(call.Arguments), &arguments); err != nil {
275+
return call.Name
276+
}
277+
switch call.Name {
278+
case "read_files":
279+
return "read " + strings.Join(arguments.Paths, ", ")
280+
case "search":
281+
return "search " + strconv.Quote(arguments.Query)
282+
default:
283+
return call.Name
284+
}
285+
}
286+
287+
// countMapped counts the files listed in a repository map.
288+
func countMapped(repoMap string) int {
289+
count := 0
290+
for _, line := range strings.Split(repoMap, "\n") {
291+
if strings.TrimSpace(line) != "" {
292+
count++
293+
}
294+
}
295+
return count
296+
}
297+
245298
func (runner *Runner) runTool(ctx context.Context, call responseItem) string {
246299
var arguments toolArguments
247300
if err := json.Unmarshal([]byte(call.Arguments), &arguments); err != nil {

‎internal/agent/agent_test.go‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12+
"strings"
1213
"testing"
1314

1415
"github.com/mindsdb/yolocoder/internal/repo"
@@ -103,13 +104,21 @@ func TestRunnerToolPlanPatchApply(t *testing.T) {
103104

104105
client := &Client{endpoint: server.URL, apiKey: "test", model: "test", http: server.Client()}
105106
runner := NewRunner(client, repository)
106-
reply, err := runner.Run(context.Background(), "Change old to new", func(string) {})
107+
progress := &recordingProgress{}
108+
reply, err := runner.Run(context.Background(), "Change old to new", progress)
107109
if err != nil {
108110
t.Fatal(err)
109111
}
110112
if reply != "Update greeting" {
111113
t.Fatalf("reply = %q", reply)
112114
}
115+
// The permanent log is what tells the user what actually happened.
116+
trail := strings.Join(progress.logs, "\n")
117+
for _, want := range []string{"read hello.txt", "plan: Update greeting", "will edit hello.txt", "applied the patch"} {
118+
if !strings.Contains(trail, want) {
119+
t.Fatalf("progress log missing %q:\n%s", want, trail)
120+
}
121+
}
113122
content, err := os.ReadFile(filepath.Join(repository.Root, "hello.txt"))
114123
if err != nil {
115124
t.Fatal(err)
@@ -134,7 +143,7 @@ func TestRunnerRoutesNonCodingMessageWithoutTouchingRepository(t *testing.T) {
134143
client := &Client{endpoint: server.URL, apiKey: "test", model: "test", http: server.Client()}
135144
repository := &repo.Repository{Root: filepath.Join(t.TempDir(), "does-not-exist")}
136145
runner := NewRunner(client, repository)
137-
reply, err := runner.Run(context.Background(), "hi", func(string) {})
146+
reply, err := runner.Run(context.Background(), "hi", &recordingProgress{})
138147
if err != nil {
139148
t.Fatal(err)
140149
}
@@ -164,3 +173,18 @@ func integrationRepository(t *testing.T) *repo.Repository {
164173
}
165174
return &repo.Repository{Root: root}
166175
}
176+
177+
// recordingProgress captures progress output so tests can assert on the
178+
// trail the user would see.
179+
type recordingProgress struct {
180+
statuses []string
181+
logs []string
182+
}
183+
184+
func (progress *recordingProgress) Status(message string) {
185+
progress.statuses = append(progress.statuses, message)
186+
}
187+
188+
func (progress *recordingProgress) Log(message string) {
189+
progress.logs = append(progress.logs, message)
190+
}

‎internal/agent/tester.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ const maxTestOutput = 128 << 10
1414

1515
type TestResult struct {
1616
Passed bool
17-
Output string
17+
// Skipped reports that no test command was detected, so Passed is
18+
// true only because there was nothing to run.
19+
Skipped bool
20+
Output string
1821
}
1922

2023
func RunTests(ctx context.Context, root string) TestResult {
2124
name, args := detectTestCommand(root)
2225
if name == "" {
23-
return TestResult{Passed: true, Output: "No supported test command detected."}
26+
return TestResult{Passed: true, Skipped: true, Output: "No supported test command detected."}
2427
}
2528
testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
2629
defer cancel()

‎internal/app/app.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import (
1616
const Help = `YoloCoder
1717
1818
Usage:
19-
yolocoder <task> make and test a coding change
20-
yolocoder prompt for a task
19+
yolocoder <task> make and test a coding change, then exit
20+
yolocoder start an interactive session
2121
yolocoder --llm-from-env-vars <task>
2222
use OPENAI_* environment variables
2323
yolocoder config show show the saved provider

0 commit comments

Comments
 (0)