detached b7f2f12

Show edit diffs before confirmation

commit
b7f2f122918e0257a13e949e26a257ecdedf64c1
author
patriceckhart · · 2026-07-21 17:15 UTC
parent
d25ea2b
signature
unsigned
refs
tag: v0.2.98
11 files · +285 -49
M README.md +1 -1
@@ -197,7 +197,7 @@ zot --help
197197 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
198198 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
199199 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
200-| `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
200+| `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
201201
202202 ## Tools
203203
@@ -197,7 +197,7 @@ zot --help
197 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |197 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
198 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |198 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
199 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |199 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
200-| `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |200+| `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
201 201
202 ## Tools202 ## Tools
203 203
M packages/agent/cli.go +34 -7
@@ -499,18 +499,45 @@ func runInteractive(ctx context.Context, args Args, version string) error {
499499 return a
500500 }
501501 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
502- // Confirm gate runs FIRST: if the user refused, we don't
503- // waste extension-intercept time or let guards see the call.
502+ // Guards run before confirmation because they may rewrite the
503+ // arguments. The user must approve the effective call that will
504+ // actually execute, not the model's original arguments.
505+ r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
506+ if r.Block {
507+ return false, r.Reason, nil
508+ }
509+ effectiveArgs := call.Arguments
510+ if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
511+ effectiveArgs = r.ModifiedArgs
512+ }
504513 if confirmGate != nil {
505- ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
514+ var content strings.Builder
515+ if tool, err := a.Tools.Get(call.Name); err == nil {
516+ if previewer, ok := tool.(core.ToolPreviewer); ok {
517+ preview, err := previewer.Preview(ctx, effectiveArgs)
518+ if err != nil {
519+ return false, err.Error(), nil
520+ }
521+ for _, block := range preview.Content {
522+ if text, ok := block.(provider.TextBlock); ok {
523+ if content.Len() > 0 {
524+ content.WriteString("\n")
525+ }
526+ content.WriteString(text.Text)
527+ }
528+ }
529+ }
530+ }
531+ ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
532+ ID: call.ID,
533+ Name: call.Name,
534+ Summary: core.BuildPreview(effectiveArgs, 120),
535+ Content: content.String(),
536+ })
506537 if !ok {
507538 return false, reason, nil
508539 }
509540 }
510- r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
511- if r.Block {
512- return false, r.Reason, nil
513- }
514541 return true, "", r.ModifiedArgs
515542 }
516543 a.BeforeTurn = func(step int) (bool, string) {
@@ -499,18 +499,45 @@ func runInteractive(ctx context.Context, args Args, version string) error {
499 return a499 return a
500 }500 }
501 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {501 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
502- // Confirm gate runs FIRST: if the user refused, we don't502+ // Guards run before confirmation because they may rewrite the
503- // waste extension-intercept time or let guards see the call.503+ // arguments. The user must approve the effective call that will
504+ // actually execute, not the model's original arguments.
505+ r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
506+ if r.Block {
507+ return false, r.Reason, nil
508+ }
509+ effectiveArgs := call.Arguments
510+ if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
511+ effectiveArgs = r.ModifiedArgs
512+ }
504 if confirmGate != nil {513 if confirmGate != nil {
505- ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))514+ var content strings.Builder
515+ if tool, err := a.Tools.Get(call.Name); err == nil {
516+ if previewer, ok := tool.(core.ToolPreviewer); ok {
517+ preview, err := previewer.Preview(ctx, effectiveArgs)
518+ if err != nil {
519+ return false, err.Error(), nil
520+ }
521+ for _, block := range preview.Content {
522+ if text, ok := block.(provider.TextBlock); ok {
523+ if content.Len() > 0 {
524+ content.WriteString("\n")
525+ }
526+ content.WriteString(text.Text)
527+ }
528+ }
529+ }
530+ }
531+ ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
532+ ID: call.ID,
533+ Name: call.Name,
534+ Summary: core.BuildPreview(effectiveArgs, 120),
535+ Content: content.String(),
536+ })
506 if !ok {537 if !ok {
507 return false, reason, nil538 return false, reason, nil
508 }539 }
509 }540 }
510- r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
511- if r.Block {
512- return false, r.Reason, nil
513- }
514 return true, "", r.ModifiedArgs541 return true, "", r.ModifiedArgs
515 }542 }
516 a.BeforeTurn = func(step int) (bool, string) {543 a.BeforeTurn = func(step int) (bool, string) {
M packages/agent/modes/confirm_dialog_test.go +46 -0
@@ -3,13 +3,59 @@ package modes
33 import (
44 "context"
55 "encoding/json"
6+ "runtime"
67 "strings"
78 "testing"
9+ "time"
810
911 "github.com/patriceckhart/zot/packages/core"
1012 "github.com/patriceckhart/zot/packages/tui"
1113 )
1214
15+func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
16+ dialog := newConfirmDialog()
17+ i := &Interactive{
18+ toolCalls: map[string]*tui.ToolCallView{
19+ "call-1": {ID: "call-1", Name: "edit"},
20+ },
21+ confirmDialog: dialog,
22+ dirty: make(chan struct{}, 1),
23+ }
24+ decision := make(chan core.ConfirmDecision, 1)
25+ go func() {
26+ decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
27+ ID: "call-1",
28+ Name: "edit",
29+ Summary: "sample.go",
30+ Content: "-old\n+new\n",
31+ })
32+ }()
33+
34+ deadline := time.Now().Add(time.Second)
35+ for !dialog.Active() && time.Now().Before(deadline) {
36+ runtime.Gosched()
37+ }
38+ if !dialog.Active() {
39+ t.Fatal("confirmation dialog did not open")
40+ }
41+ i.mu.Lock()
42+ preview := i.toolCalls["call-1"].Preview
43+ i.mu.Unlock()
44+ if preview != "-old\n+new\n" {
45+ t.Fatalf("tool preview = %q", preview)
46+ }
47+
48+ dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
49+ select {
50+ case got := <-decision:
51+ if !got.Allow {
52+ t.Fatalf("confirmation decision = %+v", got)
53+ }
54+ case <-time.After(time.Second):
55+ t.Fatal("confirmation did not return")
56+ }
57+}
58+
1359 func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
1460 resp := make(chan core.ConfirmDecision, 1)
1561 dialog := newConfirmDialog()
@@ -3,13 +3,59 @@ package modes
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "runtime"
6 "strings"7 "strings"
7 "testing"8 "testing"
9+ "time"
8 10
9 "github.com/patriceckhart/zot/packages/core"11 "github.com/patriceckhart/zot/packages/core"
10 "github.com/patriceckhart/zot/packages/tui"12 "github.com/patriceckhart/zot/packages/tui"
11 )13 )
12 14
15+func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
16+ dialog := newConfirmDialog()
17+ i := &Interactive{
18+ toolCalls: map[string]*tui.ToolCallView{
19+ "call-1": {ID: "call-1", Name: "edit"},
20+ },
21+ confirmDialog: dialog,
22+ dirty: make(chan struct{}, 1),
23+ }
24+ decision := make(chan core.ConfirmDecision, 1)
25+ go func() {
26+ decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
27+ ID: "call-1",
28+ Name: "edit",
29+ Summary: "sample.go",
30+ Content: "-old\n+new\n",
31+ })
32+ }()
33+
34+ deadline := time.Now().Add(time.Second)
35+ for !dialog.Active() && time.Now().Before(deadline) {
36+ runtime.Gosched()
37+ }
38+ if !dialog.Active() {
39+ t.Fatal("confirmation dialog did not open")
40+ }
41+ i.mu.Lock()
42+ preview := i.toolCalls["call-1"].Preview
43+ i.mu.Unlock()
44+ if preview != "-old\n+new\n" {
45+ t.Fatalf("tool preview = %q", preview)
46+ }
47+
48+ dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
49+ select {
50+ case got := <-decision:
51+ if !got.Allow {
52+ t.Fatalf("confirmation decision = %+v", got)
53+ }
54+ case <-time.After(time.Second):
55+ t.Fatal("confirmation did not return")
56+ }
57+}
58+
13 func TestConfirmDialogAllowsToolExpansion(t *testing.T) {59 func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
14 resp := make(chan core.ConfirmDecision, 1)60 resp := make(chan core.ConfirmDecision, 1)
15 dialog := newConfirmDialog()61 dialog := newConfirmDialog()
M packages/agent/modes/interactive.go +17 -2
@@ -5405,6 +5405,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
54055405 if tc, ok := i.toolCalls[e.ID]; ok {
54065406 tc.Done = true
54075407 tc.Error = e.Result.IsError
5408+ tc.Preview = ""
54085409 var text strings.Builder
54095410 for _, c := range e.Result.Content {
54105411 if tb, ok := c.(provider.TextBlock); ok {
@@ -5506,10 +5507,24 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
55065507 // pending request is refused via CancelAll so the agent doesn't
55075508 // deadlock.
55085509 func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
5510+ return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
5511+}
5512+
5513+// ConfirmToolCall attaches a side-effect-free preview to the matching live
5514+// tool panel, then blocks until the user approves or refuses the call.
5515+func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
55095516 resp := make(chan core.ConfirmDecision, 1)
5517+ if call.ID != "" {
5518+ i.mu.Lock()
5519+ if tc, ok := i.toolCalls[call.ID]; ok {
5520+ tc.Args = call.Summary
5521+ tc.Preview = call.Content
5522+ }
5523+ i.mu.Unlock()
5524+ }
55105525 i.confirmDialog.Enqueue(&confirmRequest{
5511- toolName: toolName,
5512- preview: preview,
5526+ toolName: call.Name,
5527+ preview: call.Summary,
55135528 resp: resp,
55145529 })
55155530 i.invalidate()
@@ -5405,6 +5405,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
5405 if tc, ok := i.toolCalls[e.ID]; ok {5405 if tc, ok := i.toolCalls[e.ID]; ok {
5406 tc.Done = true5406 tc.Done = true
5407 tc.Error = e.Result.IsError5407 tc.Error = e.Result.IsError
5408+ tc.Preview = ""
5408 var text strings.Builder5409 var text strings.Builder
5409 for _, c := range e.Result.Content {5410 for _, c := range e.Result.Content {
5410 if tb, ok := c.(provider.TextBlock); ok {5411 if tb, ok := c.(provider.TextBlock); ok {
@@ -5506,10 +5507,24 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
5506 // pending request is refused via CancelAll so the agent doesn't5507 // pending request is refused via CancelAll so the agent doesn't
5507 // deadlock.5508 // deadlock.
5508 func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {5509 func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
5510+ return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
5511+}
5512+
5513+// ConfirmToolCall attaches a side-effect-free preview to the matching live
5514+// tool panel, then blocks until the user approves or refuses the call.
5515+func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
5509 resp := make(chan core.ConfirmDecision, 1)5516 resp := make(chan core.ConfirmDecision, 1)
5517+ if call.ID != "" {
5518+ i.mu.Lock()
5519+ if tc, ok := i.toolCalls[call.ID]; ok {
5520+ tc.Args = call.Summary
5521+ tc.Preview = call.Content
5522+ }
5523+ i.mu.Unlock()
5524+ }
5510 i.confirmDialog.Enqueue(&confirmRequest{5525 i.confirmDialog.Enqueue(&confirmRequest{
5511- toolName: toolName,5526+ toolName: call.Name,
5512- preview: preview,5527+ preview: call.Summary,
5513 resp: resp,5528 resp: resp,
5514 })5529 })
5515 i.invalidate()5530 i.invalidate()
M packages/agent/tools/edit.go +43 -20
@@ -36,25 +36,53 @@ func (t *EditTool) Description() string {
3636 }
3737 func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
3838
39+type editPlan struct {
40+ path string
41+ final []byte
42+ result core.ToolResult
43+}
44+
45+// Preview validates the edit against the current file and returns the exact
46+// diff without writing anything. The confirmation UI shows this result before
47+// the user decides whether Execute may apply it.
48+func (t *EditTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
49+ plan, err := t.plan(raw)
50+ if err != nil {
51+ return core.ToolResult{}, err
52+ }
53+ return plan.result, nil
54+}
55+
3956 func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
57+ plan, err := t.plan(raw)
58+ if err != nil {
59+ return core.ToolResult{}, err
60+ }
61+ if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
62+ return core.ToolResult{}, err
63+ }
64+ return plan.result, nil
65+}
66+
67+func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
4068 var a editArgs
4169 if err := json.Unmarshal(raw, &a); err != nil {
42- return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
70+ return editPlan{}, fmt.Errorf("invalid args: %w", err)
4371 }
4472 if a.Path == "" {
45- return core.ToolResult{}, fmt.Errorf("path is required")
73+ return editPlan{}, fmt.Errorf("path is required")
4674 }
4775 if len(a.Edits) == 0 {
48- return core.ToolResult{}, fmt.Errorf("at least one edit is required")
76+ return editPlan{}, fmt.Errorf("at least one edit is required")
4977 }
5078 path := resolvePath(t.CWD, a.Path)
5179 if err := t.Sandbox.CheckWritePath(path); err != nil {
52- return core.ToolResult{}, err
80+ return editPlan{}, err
5381 }
5482
5583 orig, err := os.ReadFile(path)
5684 if err != nil {
57- return core.ToolResult{}, err
85+ return editPlan{}, err
5886 }
5987
6088 // Detect BOM and line endings.
@@ -70,17 +98,17 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
7098 // Validate all edits first (against original content, not sequentially).
7199 for i, e := range a.Edits {
72100 if e.OldText == "" {
73- return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
101+ return editPlan{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
74102 }
75103 if e.OldText == e.NewText {
76- return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
104+ return editPlan{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
77105 }
78106 count := strings.Count(body, e.OldText)
79107 if count == 0 {
80- return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
108+ return editPlan{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
81109 }
82110 if count > 1 {
83- return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
111+ return editPlan{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
84112 }
85113 }
86114
@@ -98,7 +126,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
98126 for i := 0; i < len(spans); i++ {
99127 for j := i + 1; j < len(spans); j++ {
100128 if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
101- return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")
129+ return editPlan{}, fmt.Errorf("edits overlap; merge them into one edit")
102130 }
103131 }
104132 }
@@ -125,20 +153,15 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
125153 final := append([]byte{}, bom...)
126154 final = append(final, []byte(newBody)...)
127155
128- if err := os.WriteFile(path, final, 0o644); err != nil {
129- return core.ToolResult{}, err
130- }
131-
132156 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
133157 // The tool-call header renders the path above the result, so the
134- // result body is just the context diff — no "applied N edit(s)"
135- // prose prefix. The Details map carries the edit count for
136- // programmatic consumers (json mode, rpc clients) that might
137- // want it.
138- return core.ToolResult{
158+ // result body is just the context diff. Details carries metadata for
159+ // programmatic consumers and confirmation previews.
160+ result := core.ToolResult{
139161 Content: []provider.Content{provider.TextBlock{Text: diff}},
140162 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
141- }, nil
163+ }
164+ return editPlan{path: path, final: final, result: result}, nil
142165 }
143166
144167 func detectLineEnding(b []byte) string {
@@ -36,25 +36,53 @@ func (t *EditTool) Description() string {
36 }36 }
37 func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }37 func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
38 38
39+type editPlan struct {
40+ path string
41+ final []byte
42+ result core.ToolResult
43+}
44+
45+// Preview validates the edit against the current file and returns the exact
46+// diff without writing anything. The confirmation UI shows this result before
47+// the user decides whether Execute may apply it.
48+func (t *EditTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
49+ plan, err := t.plan(raw)
50+ if err != nil {
51+ return core.ToolResult{}, err
52+ }
53+ return plan.result, nil
54+}
55+
39 func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {56 func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
57+ plan, err := t.plan(raw)
58+ if err != nil {
59+ return core.ToolResult{}, err
60+ }
61+ if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
62+ return core.ToolResult{}, err
63+ }
64+ return plan.result, nil
65+}
66+
67+func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
40 var a editArgs68 var a editArgs
41 if err := json.Unmarshal(raw, &a); err != nil {69 if err := json.Unmarshal(raw, &a); err != nil {
42- return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)70+ return editPlan{}, fmt.Errorf("invalid args: %w", err)
43 }71 }
44 if a.Path == "" {72 if a.Path == "" {
45- return core.ToolResult{}, fmt.Errorf("path is required")73+ return editPlan{}, fmt.Errorf("path is required")
46 }74 }
47 if len(a.Edits) == 0 {75 if len(a.Edits) == 0 {
48- return core.ToolResult{}, fmt.Errorf("at least one edit is required")76+ return editPlan{}, fmt.Errorf("at least one edit is required")
49 }77 }
50 path := resolvePath(t.CWD, a.Path)78 path := resolvePath(t.CWD, a.Path)
51 if err := t.Sandbox.CheckWritePath(path); err != nil {79 if err := t.Sandbox.CheckWritePath(path); err != nil {
52- return core.ToolResult{}, err80+ return editPlan{}, err
53 }81 }
54 82
55 orig, err := os.ReadFile(path)83 orig, err := os.ReadFile(path)
56 if err != nil {84 if err != nil {
57- return core.ToolResult{}, err85+ return editPlan{}, err
58 }86 }
59 87
60 // Detect BOM and line endings.88 // Detect BOM and line endings.
@@ -70,17 +98,17 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
70 // Validate all edits first (against original content, not sequentially).98 // Validate all edits first (against original content, not sequentially).
71 for i, e := range a.Edits {99 for i, e := range a.Edits {
72 if e.OldText == "" {100 if e.OldText == "" {
73- return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)101+ return editPlan{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
74 }102 }
75 if e.OldText == e.NewText {103 if e.OldText == e.NewText {
76- return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)104+ return editPlan{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
77 }105 }
78 count := strings.Count(body, e.OldText)106 count := strings.Count(body, e.OldText)
79 if count == 0 {107 if count == 0 {
80- return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)108+ return editPlan{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
81 }109 }
82 if count > 1 {110 if count > 1 {
83- return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)111+ return editPlan{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
84 }112 }
85 }113 }
86 114
@@ -98,7 +126,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
98 for i := 0; i < len(spans); i++ {126 for i := 0; i < len(spans); i++ {
99 for j := i + 1; j < len(spans); j++ {127 for j := i + 1; j < len(spans); j++ {
100 if spans[i].start < spans[j].end && spans[j].start < spans[i].end {128 if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
101- return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")129+ return editPlan{}, fmt.Errorf("edits overlap; merge them into one edit")
102 }130 }
103 }131 }
104 }132 }
@@ -125,20 +153,15 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
125 final := append([]byte{}, bom...)153 final := append([]byte{}, bom...)
126 final = append(final, []byte(newBody)...)154 final = append(final, []byte(newBody)...)
127 155
128- if err := os.WriteFile(path, final, 0o644); err != nil {
129- return core.ToolResult{}, err
130- }
131-
132 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))156 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
133 // The tool-call header renders the path above the result, so the157 // The tool-call header renders the path above the result, so the
134- // result body is just the context diff — no "applied N edit(s)"158+ // result body is just the context diff. Details carries metadata for
135- // prose prefix. The Details map carries the edit count for159+ // programmatic consumers and confirmation previews.
136- // programmatic consumers (json mode, rpc clients) that might160+ result := core.ToolResult{
137- // want it.
138- return core.ToolResult{
139 Content: []provider.Content{provider.TextBlock{Text: diff}},161 Content: []provider.Content{provider.TextBlock{Text: diff}},
140 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},162 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
141- }, nil163+ }
164+ return editPlan{path: path, final: final, result: result}, nil
142 }165 }
143 166
144 func detectLineEnding(b []byte) string {167 func detectLineEnding(b []byte) string {
M packages/agent/tools/tools_test.go +38 -0
@@ -123,6 +123,44 @@ func TestEditSingle(t *testing.T) {
123123 }
124124 }
125125
126+func TestEditPreviewReturnsDiffWithoutWriting(t *testing.T) {
127+ dir := t.TempDir()
128+ p := filepath.Join(dir, "a.txt")
129+ if err := os.WriteFile(p, []byte("hello world\n"), 0o644); err != nil {
130+ t.Fatal(err)
131+ }
132+ tool := &EditTool{CWD: dir}
133+ args := mustJSON(t, map[string]any{
134+ "path": "a.txt",
135+ "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
136+ })
137+ preview, err := tool.Preview(context.Background(), args)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ text := preview.Content[0].(provider.TextBlock).Text
142+ for _, want := range []string{"-hello world", "+hello gopher"} {
143+ if !strings.Contains(text, want) {
144+ t.Fatalf("preview missing %q:\n%s", want, text)
145+ }
146+ }
147+ b, err := os.ReadFile(p)
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ if string(b) != "hello world\n" {
152+ t.Fatalf("preview modified file: %q", b)
153+ }
154+
155+ result, err := tool.Execute(context.Background(), args, nil)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ if got := result.Content[0].(provider.TextBlock).Text; got != text {
160+ t.Fatalf("executed diff differs from preview:\npreview:\n%s\nresult:\n%s", text, got)
161+ }
162+}
163+
126164 func TestEditMultiple(t *testing.T) {
127165 dir := t.TempDir()
128166 p := filepath.Join(dir, "a.txt")
@@ -123,6 +123,44 @@ func TestEditSingle(t *testing.T) {
123 }123 }
124 }124 }
125 125
126+func TestEditPreviewReturnsDiffWithoutWriting(t *testing.T) {
127+ dir := t.TempDir()
128+ p := filepath.Join(dir, "a.txt")
129+ if err := os.WriteFile(p, []byte("hello world\n"), 0o644); err != nil {
130+ t.Fatal(err)
131+ }
132+ tool := &EditTool{CWD: dir}
133+ args := mustJSON(t, map[string]any{
134+ "path": "a.txt",
135+ "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
136+ })
137+ preview, err := tool.Preview(context.Background(), args)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ text := preview.Content[0].(provider.TextBlock).Text
142+ for _, want := range []string{"-hello world", "+hello gopher"} {
143+ if !strings.Contains(text, want) {
144+ t.Fatalf("preview missing %q:\n%s", want, text)
145+ }
146+ }
147+ b, err := os.ReadFile(p)
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ if string(b) != "hello world\n" {
152+ t.Fatalf("preview modified file: %q", b)
153+ }
154+
155+ result, err := tool.Execute(context.Background(), args, nil)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ if got := result.Content[0].(provider.TextBlock).Text; got != text {
160+ t.Fatalf("executed diff differs from preview:\npreview:\n%s\nresult:\n%s", text, got)
161+ }
162+}
163+
126 func TestEditMultiple(t *testing.T) {164 func TestEditMultiple(t *testing.T) {
127 dir := t.TempDir()165 dir := t.TempDir()
128 p := filepath.Join(dir, "a.txt")166 p := filepath.Join(dir, "a.txt")
M packages/core/confirm.go +29 -6
@@ -37,6 +37,21 @@ type Confirmer interface {
3737 Confirm(toolName string, preview string) ConfirmDecision
3838 }
3939
40+// ToolCallConfirmation contains the summary and optional side-effect-free
41+// content shown while approving a specific tool call.
42+type ToolCallConfirmation struct {
43+ ID string
44+ Name string
45+ Summary string
46+ Content string
47+}
48+
49+// ToolCallConfirmer is optionally implemented by confirmation UIs that can
50+// attach rich preview content to the matching live tool call.
51+type ToolCallConfirmer interface {
52+ ConfirmToolCall(call ToolCallConfirmation) ConfirmDecision
53+}
54+
4055 // ConfirmGate wraps a Confirmer with session-scoped memory for the
4156 // "allow, always" decisions. Once the user picks RememberTool on a
4257 // given tool name, the gate short-circuits subsequent calls of that
@@ -70,6 +85,12 @@ func NewConfirmGate(inner Confirmer) *ConfirmGate {
7085 //
7186 // A nil ConfirmGate always allows (treat as yolo mode).
7287 func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) {
88+ return g.CheckToolCall(ToolCallConfirmation{Name: toolName, Summary: preview})
89+}
90+
91+// CheckToolCall behaves like Check and additionally gives rich confirmation
92+// UIs the call ID and side-effect-free preview content.
93+func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, json.RawMessage) {
7394 if g == nil {
7495 return true, "", nil
7596 }
@@ -78,20 +99,22 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes
7899 g.mu.Unlock()
79100 return true, "", nil
80101 }
81- if g.allowedTool[toolName] {
102+ if g.allowedTool[call.Name] {
82103 g.mu.Unlock()
83104 return true, "", nil
84105 }
85- g.mu.Unlock()
86-
87- g.mu.Lock()
88106 inner := g.inner
89107 g.mu.Unlock()
90108 if inner == nil {
91109 return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil
92110 }
93111
94- decision := inner.Confirm(toolName, preview)
112+ var decision ConfirmDecision
113+ if detailed, ok := inner.(ToolCallConfirmer); ok {
114+ decision = detailed.ConfirmToolCall(call)
115+ } else {
116+ decision = inner.Confirm(call.Name, call.Summary)
117+ }
95118
96119 g.mu.Lock()
97120 if decision.Allow {
@@ -99,7 +122,7 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes
99122 g.allowAll = true
100123 }
101124 if decision.RememberTool {
102- g.allowedTool[toolName] = true
125+ g.allowedTool[call.Name] = true
103126 }
104127 }
105128 g.mu.Unlock()
@@ -37,6 +37,21 @@ type Confirmer interface {
37 Confirm(toolName string, preview string) ConfirmDecision37 Confirm(toolName string, preview string) ConfirmDecision
38 }38 }
39 39
40+// ToolCallConfirmation contains the summary and optional side-effect-free
41+// content shown while approving a specific tool call.
42+type ToolCallConfirmation struct {
43+ ID string
44+ Name string
45+ Summary string
46+ Content string
47+}
48+
49+// ToolCallConfirmer is optionally implemented by confirmation UIs that can
50+// attach rich preview content to the matching live tool call.
51+type ToolCallConfirmer interface {
52+ ConfirmToolCall(call ToolCallConfirmation) ConfirmDecision
53+}
54+
40 // ConfirmGate wraps a Confirmer with session-scoped memory for the55 // ConfirmGate wraps a Confirmer with session-scoped memory for the
41 // "allow, always" decisions. Once the user picks RememberTool on a56 // "allow, always" decisions. Once the user picks RememberTool on a
42 // given tool name, the gate short-circuits subsequent calls of that57 // given tool name, the gate short-circuits subsequent calls of that
@@ -70,6 +85,12 @@ func NewConfirmGate(inner Confirmer) *ConfirmGate {
70 //85 //
71 // A nil ConfirmGate always allows (treat as yolo mode).86 // A nil ConfirmGate always allows (treat as yolo mode).
72 func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) {87 func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) {
88+ return g.CheckToolCall(ToolCallConfirmation{Name: toolName, Summary: preview})
89+}
90+
91+// CheckToolCall behaves like Check and additionally gives rich confirmation
92+// UIs the call ID and side-effect-free preview content.
93+func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, json.RawMessage) {
73 if g == nil {94 if g == nil {
74 return true, "", nil95 return true, "", nil
75 }96 }
@@ -78,20 +99,22 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes
78 g.mu.Unlock()99 g.mu.Unlock()
79 return true, "", nil100 return true, "", nil
80 }101 }
81- if g.allowedTool[toolName] {102+ if g.allowedTool[call.Name] {
82 g.mu.Unlock()103 g.mu.Unlock()
83 return true, "", nil104 return true, "", nil
84 }105 }
85- g.mu.Unlock()
86-
87- g.mu.Lock()
88 inner := g.inner106 inner := g.inner
89 g.mu.Unlock()107 g.mu.Unlock()
90 if inner == nil {108 if inner == nil {
91 return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil109 return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil
92 }110 }
93 111
94- decision := inner.Confirm(toolName, preview)112+ var decision ConfirmDecision
113+ if detailed, ok := inner.(ToolCallConfirmer); ok {
114+ decision = detailed.ConfirmToolCall(call)
115+ } else {
116+ decision = inner.Confirm(call.Name, call.Summary)
117+ }
95 118
96 g.mu.Lock()119 g.mu.Lock()
97 if decision.Allow {120 if decision.Allow {
@@ -99,7 +122,7 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes
99 g.allowAll = true122 g.allowAll = true
100 }123 }
101 if decision.RememberTool {124 if decision.RememberTool {
102- g.allowedTool[toolName] = true125+ g.allowedTool[call.Name] = true
103 }126 }
104 }127 }
105 g.mu.Unlock()128 g.mu.Unlock()
M packages/core/confirm_test.go +27 -0
@@ -26,6 +26,33 @@ func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision {
2626 return d
2727 }
2828
29+type detailedConfirmer struct {
30+ recordingConfirmer
31+ call ToolCallConfirmation
32+}
33+
34+func (d *detailedConfirmer) ConfirmToolCall(call ToolCallConfirmation) ConfirmDecision {
35+ d.call = call
36+ return ConfirmDecision{Allow: true}
37+}
38+
39+func TestConfirmGatePassesRichToolCallPreview(t *testing.T) {
40+ confirmer := &detailedConfirmer{}
41+ gate := NewConfirmGate(confirmer)
42+ call := ToolCallConfirmation{
43+ ID: "call-1",
44+ Name: "edit",
45+ Summary: "sample.go",
46+ Content: "-old\n+new\n",
47+ }
48+ if allow, _, _ := gate.CheckToolCall(call); !allow {
49+ t.Fatal("rich confirmation should allow")
50+ }
51+ if confirmer.call != call {
52+ t.Fatalf("confirmation = %+v, want %+v", confirmer.call, call)
53+ }
54+}
55+
2956 func TestConfirmGateNilAllowsEverything(t *testing.T) {
3057 var g *ConfirmGate
3158 allow, reason, args := g.Check("bash", "rm -rf /")
@@ -26,6 +26,33 @@ func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision {
26 return d26 return d
27 }27 }
28 28
29+type detailedConfirmer struct {
30+ recordingConfirmer
31+ call ToolCallConfirmation
32+}
33+
34+func (d *detailedConfirmer) ConfirmToolCall(call ToolCallConfirmation) ConfirmDecision {
35+ d.call = call
36+ return ConfirmDecision{Allow: true}
37+}
38+
39+func TestConfirmGatePassesRichToolCallPreview(t *testing.T) {
40+ confirmer := &detailedConfirmer{}
41+ gate := NewConfirmGate(confirmer)
42+ call := ToolCallConfirmation{
43+ ID: "call-1",
44+ Name: "edit",
45+ Summary: "sample.go",
46+ Content: "-old\n+new\n",
47+ }
48+ if allow, _, _ := gate.CheckToolCall(call); !allow {
49+ t.Fatal("rich confirmation should allow")
50+ }
51+ if confirmer.call != call {
52+ t.Fatalf("confirmation = %+v, want %+v", confirmer.call, call)
53+ }
54+}
55+
29 func TestConfirmGateNilAllowsEverything(t *testing.T) {56 func TestConfirmGateNilAllowsEverything(t *testing.T) {
30 var g *ConfirmGate57 var g *ConfirmGate
31 allow, reason, args := g.Check("bash", "rm -rf /")58 allow, reason, args := g.Check("bash", "rm -rf /")
M packages/core/tool.go +7 -0
@@ -25,6 +25,13 @@ type Tool interface {
2525 Execute(ctx context.Context, args json.RawMessage, progress func(string)) (ToolResult, error)
2626 }
2727
28+// ToolPreviewer is optionally implemented by tools that can describe their
29+// exact effect without applying it. Confirmation UIs use the preview before
30+// allowing a side-effecting call to proceed.
31+type ToolPreviewer interface {
32+ Preview(ctx context.Context, args json.RawMessage) (ToolResult, error)
33+}
34+
2835 // ToolResult is the outcome of Tool.Execute.
2936 type ToolResult struct {
3037 // Content is sent back to the LLM (text and/or images).
@@ -25,6 +25,13 @@ type Tool interface {
25 Execute(ctx context.Context, args json.RawMessage, progress func(string)) (ToolResult, error)25 Execute(ctx context.Context, args json.RawMessage, progress func(string)) (ToolResult, error)
26 }26 }
27 27
28+// ToolPreviewer is optionally implemented by tools that can describe their
29+// exact effect without applying it. Confirmation UIs use the preview before
30+// allowing a side-effecting call to proceed.
31+type ToolPreviewer interface {
32+ Preview(ctx context.Context, args json.RawMessage) (ToolResult, error)
33+}
34+
28 // ToolResult is the outcome of Tool.Execute.35 // ToolResult is the outcome of Tool.Execute.
29 type ToolResult struct {36 type ToolResult struct {
30 // Content is sent back to the LLM (text and/or images).37 // Content is sent back to the LLM (text and/or images).
M packages/tui/view.go +19 -13
@@ -212,12 +212,13 @@ const (
212212
213213 // ToolCallView is a pending tool invocation plus optional result.
214214 type ToolCallView struct {
215- ID string
216- Name string
217- Args string // rendered argument summary
218- Result string // rendered result preview (truncated)
219- Error bool
220- Done bool
215+ ID string
216+ Name string
217+ Args string // rendered argument summary
218+ Preview string // side-effect-free result shown before confirmation
219+ Result string // rendered result preview (truncated)
220+ Error bool
221+ Done bool
221222
222223 // Streaming is true while the model is still typing the tool
223224 // call's JSON arguments. The TUI renders a live preview of any
@@ -822,11 +823,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
822823 // when the overlay disappears.
823824
824825 // Live body (write/edit): keep the streamed preview visible until
825- // a real tool result arrives. The provider can finish the tool_use
826- // JSON before zot has executed the tool, so keying this on
826+ // a proposed or real tool result arrives. The provider can finish the
827+ // tool_use JSON before zot has executed the tool, so keying this on
827828 // tc.Streaming makes write/edit boxes collapse for a moment between
828- // EvToolUseEnd and EvToolResult.
829- if tc.Result == "" {
829+ // EvToolUseEnd and confirmation or EvToolResult.
830+ if tc.Preview == "" && tc.Result == "" {
830831 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
831832 // Pad the live preview up to the call's high-water height so
832833 // it never shrinks mid-stream (e.g. between edit 1 and the
@@ -867,10 +868,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
867868 }
868869 }
869870
871+ bodyText := tc.Result
872+ if bodyText == "" {
873+ bodyText = tc.Preview
874+ }
875+
870876 // Finished tool call with no body: just the labelled top edge
871877 // directly closed by the bottom. Avoids a blank interior row
872878 // for no-output tools.
873- if tc.Result == "" {
879+ if bodyText == "" {
874880 if v.FlatTools || v.CompactMode {
875881 if v.CompactMode {
876882 lines = append(lines, compactToolBlank(v.Theme, width))
@@ -900,7 +906,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
900906 lines = append(lines, compactToolBlank(v.Theme, width))
901907 }
902908 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
903- body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
909+ body := toolResultBlock(v.Theme, bodyText, flatToolBodyRenderWidth(width), color)
904910 for _, l := range v.collapseToolBody(body, false) {
905911 _, stripped := parseImageFootprint(l)
906912 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
@@ -912,7 +918,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
912918 }
913919 lines = append(lines, toolBoxTop(v.Theme, label, width))
914920 lines = append(lines, toolBoxSide(v.Theme, "", width))
915- body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
921+ body := toolResultBlock(v.Theme, bodyText, toolBoxBodyRenderWidth(width), color)
916922 for _, l := range v.collapseToolBody(body, false) {
917923 imgCells, stripped := parseImageFootprint(l)
918924 if hasImageEscapeLine(stripped) {
@@ -212,12 +212,13 @@ const (
212 212
213 // ToolCallView is a pending tool invocation plus optional result.213 // ToolCallView is a pending tool invocation plus optional result.
214 type ToolCallView struct {214 type ToolCallView struct {
215- ID string215+ ID string
216- Name string216+ Name string
217- Args string // rendered argument summary217+ Args string // rendered argument summary
218- Result string // rendered result preview (truncated)218+ Preview string // side-effect-free result shown before confirmation
219- Error bool219+ Result string // rendered result preview (truncated)
220- Done bool220+ Error bool
221+ Done bool
221 222
222 // Streaming is true while the model is still typing the tool223 // Streaming is true while the model is still typing the tool
223 // call's JSON arguments. The TUI renders a live preview of any224 // call's JSON arguments. The TUI renders a live preview of any
@@ -822,11 +823,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
822 // when the overlay disappears.823 // when the overlay disappears.
823 824
824 // Live body (write/edit): keep the streamed preview visible until825 // Live body (write/edit): keep the streamed preview visible until
825- // a real tool result arrives. The provider can finish the tool_use826+ // a proposed or real tool result arrives. The provider can finish the
826- // JSON before zot has executed the tool, so keying this on827+ // tool_use JSON before zot has executed the tool, so keying this on
827 // tc.Streaming makes write/edit boxes collapse for a moment between828 // tc.Streaming makes write/edit boxes collapse for a moment between
828- // EvToolUseEnd and EvToolResult.829+ // EvToolUseEnd and confirmation or EvToolResult.
829- if tc.Result == "" {830+ if tc.Preview == "" && tc.Result == "" {
830 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {831 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
831 // Pad the live preview up to the call's high-water height so832 // Pad the live preview up to the call's high-water height so
832 // it never shrinks mid-stream (e.g. between edit 1 and the833 // it never shrinks mid-stream (e.g. between edit 1 and the
@@ -867,10 +868,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
867 }868 }
868 }869 }
869 870
871+ bodyText := tc.Result
872+ if bodyText == "" {
873+ bodyText = tc.Preview
874+ }
875+
870 // Finished tool call with no body: just the labelled top edge876 // Finished tool call with no body: just the labelled top edge
871 // directly closed by the bottom. Avoids a blank interior row877 // directly closed by the bottom. Avoids a blank interior row
872 // for no-output tools.878 // for no-output tools.
873- if tc.Result == "" {879+ if bodyText == "" {
874 if v.FlatTools || v.CompactMode {880 if v.FlatTools || v.CompactMode {
875 if v.CompactMode {881 if v.CompactMode {
876 lines = append(lines, compactToolBlank(v.Theme, width))882 lines = append(lines, compactToolBlank(v.Theme, width))
@@ -900,7 +906,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
900 lines = append(lines, compactToolBlank(v.Theme, width))906 lines = append(lines, compactToolBlank(v.Theme, width))
901 }907 }
902 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))908 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
903- body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)909+ body := toolResultBlock(v.Theme, bodyText, flatToolBodyRenderWidth(width), color)
904 for _, l := range v.collapseToolBody(body, false) {910 for _, l := range v.collapseToolBody(body, false) {
905 _, stripped := parseImageFootprint(l)911 _, stripped := parseImageFootprint(l)
906 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))912 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
@@ -912,7 +918,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
912 }918 }
913 lines = append(lines, toolBoxTop(v.Theme, label, width))919 lines = append(lines, toolBoxTop(v.Theme, label, width))
914 lines = append(lines, toolBoxSide(v.Theme, "", width))920 lines = append(lines, toolBoxSide(v.Theme, "", width))
915- body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)921+ body := toolResultBlock(v.Theme, bodyText, toolBoxBodyRenderWidth(width), color)
916 for _, l := range v.collapseToolBody(body, false) {922 for _, l := range v.collapseToolBody(body, false) {
917 imgCells, stripped := parseImageFootprint(l)923 imgCells, stripped := parseImageFootprint(l)
918 if hasImageEscapeLine(stripped) {924 if hasImageEscapeLine(stripped) {
M packages/tui/view_tool_overlay_test.go +24 -0
@@ -74,6 +74,30 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
7474 }
7575 }
7676
77+func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
78+ args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
79+ v := View{
80+ Theme: Dark,
81+ ToolCalls: []ToolCallView{{
82+ ID: "toolu_1",
83+ Name: "edit",
84+ RawJSONBuf: string(args),
85+ LivePath: "sample.go",
86+ Preview: "-old value\n+new value\n",
87+ }},
88+ }
89+
90+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
91+ for _, want := range []string{"-old value", "+new value"} {
92+ if !strings.Contains(plain, want) {
93+ t.Fatalf("confirmation preview missing %q:\n%s", want, plain)
94+ }
95+ }
96+ if strings.Contains(plain, "edit 1 (streaming)") {
97+ t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
98+ }
99+}
100+
77101 func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
78102 args := json.RawMessage(`{"path":"sample.ts","content":"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20"}`)
79103 v := View{
@@ -74,6 +74,30 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
74 }74 }
75 }75 }
76 76
77+func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
78+ args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
79+ v := View{
80+ Theme: Dark,
81+ ToolCalls: []ToolCallView{{
82+ ID: "toolu_1",
83+ Name: "edit",
84+ RawJSONBuf: string(args),
85+ LivePath: "sample.go",
86+ Preview: "-old value\n+new value\n",
87+ }},
88+ }
89+
90+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
91+ for _, want := range []string{"-old value", "+new value"} {
92+ if !strings.Contains(plain, want) {
93+ t.Fatalf("confirmation preview missing %q:\n%s", want, plain)
94+ }
95+ }
96+ if strings.Contains(plain, "edit 1 (streaming)") {
97+ t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
98+ }
99+}
100+
77 func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {101 func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
78 args := json.RawMessage(`{"path":"sample.ts","content":"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20"}`)102 args := json.RawMessage(`{"path":"sample.ts","content":"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20"}`)
79 v := View{103 v := View{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close