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 | |||
| 197 | 197 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | |
| 198 | 198 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | |
| 199 | 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 | 202 | ## Tools | |
| 203 | 203 | ||
| @@ -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 | ## Tools | 202 | ## Tools | ||
| 203 | 203 | ||||
M packages/agent/cli.go +34 -7
| @@ -499,18 +499,45 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 499 | 499 | return a | |
| 500 | 500 | } | |
| 501 | 501 | 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 | + | } | |
| 504 | 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 | 537 | if !ok { | |
| 507 | 538 | 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 | 541 | return true, "", r.ModifiedArgs | |
| 515 | 542 | } | |
| 516 | 543 | a.BeforeTurn = func(step int) (bool, string) { | |
| @@ -499,18 +499,45 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 499 | return a | 499 | 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't | 502 | + | // 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, nil | 538 | 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.ModifiedArgs | 541 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "runtime" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/patriceckhart/zot/packages/core" | |
| 10 | 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 | 59 | func TestConfirmDialogAllowsToolExpansion(t *testing.T) { | |
| 14 | 60 | resp := make(chan core.ConfirmDecision, 1) | |
| 15 | 61 | 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) { | |||
| 5405 | 5405 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 5406 | 5406 | tc.Done = true | |
| 5407 | 5407 | tc.Error = e.Result.IsError | |
| 5408 | + | tc.Preview = "" | |
| 5408 | 5409 | var text strings.Builder | |
| 5409 | 5410 | for _, c := range e.Result.Content { | |
| 5410 | 5411 | if tb, ok := c.(provider.TextBlock); ok { | |
| @@ -5506,10 +5507,24 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||
| 5506 | 5507 | // pending request is refused via CancelAll so the agent doesn't | |
| 5507 | 5508 | // deadlock. | |
| 5508 | 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 | 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 | 5525 | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 5511 | - | toolName: toolName, | |
| 5512 | - | preview: preview, | |
| 5526 | + | toolName: call.Name, | |
| 5527 | + | preview: call.Summary, | |
| 5513 | 5528 | resp: resp, | |
| 5514 | 5529 | }) | |
| 5515 | 5530 | 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 = true | 5406 | tc.Done = true | ||
| 5407 | tc.Error = e.Result.IsError | 5407 | tc.Error = e.Result.IsError | ||
| 5408 | + | tc.Preview = "" | |||
| 5408 | var text strings.Builder | 5409 | 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't | 5507 | // 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 { | |||
| 36 | 36 | } | |
| 37 | 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 | 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 | 68 | var a editArgs | |
| 41 | 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 | 72 | if a.Path == "" { | |
| 45 | - | return core.ToolResult{}, fmt.Errorf("path is required") | |
| 73 | + | return editPlan{}, fmt.Errorf("path is required") | |
| 46 | 74 | } | |
| 47 | 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 | 78 | path := resolvePath(t.CWD, a.Path) | |
| 51 | 79 | if err := t.Sandbox.CheckWritePath(path); err != nil { | |
| 52 | - | return core.ToolResult{}, err | |
| 80 | + | return editPlan{}, err | |
| 53 | 81 | } | |
| 54 | 82 | ||
| 55 | 83 | orig, err := os.ReadFile(path) | |
| 56 | 84 | if err != nil { | |
| 57 | - | return core.ToolResult{}, err | |
| 85 | + | return editPlan{}, err | |
| 58 | 86 | } | |
| 59 | 87 | ||
| 60 | 88 | // Detect BOM and line endings. | |
| @@ -70,17 +98,17 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 70 | 98 | // Validate all edits first (against original content, not sequentially). | |
| 71 | 99 | for i, e := range a.Edits { | |
| 72 | 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 | 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 | 106 | count := strings.Count(body, e.OldText) | |
| 79 | 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 | 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 | 126 | for i := 0; i < len(spans); i++ { | |
| 99 | 127 | for j := i + 1; j < len(spans); j++ { | |
| 100 | 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 | 153 | final := append([]byte{}, bom...) | |
| 126 | 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 | 156 | diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n")) | |
| 133 | 157 | // 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{ | |
| 139 | 161 | Content: []provider.Content{provider.TextBlock{Text: diff}}, | |
| 140 | 162 | 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 | |
| 142 | 165 | } | |
| 143 | 166 | ||
| 144 | 167 | 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 editArgs | 68 | 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{}, err | 80 | + | 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{}, err | 85 | + | 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 the | 157 | // 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 for | 159 | + | // programmatic consumers and confirmation previews. |
| 136 | - | // programmatic consumers (json mode, rpc clients) that might | 160 | + | 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 | - | }, nil | 163 | + | } |
| 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) { | |||
| 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 | 164 | func TestEditMultiple(t *testing.T) { | |
| 127 | 165 | dir := t.TempDir() | |
| 128 | 166 | 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 { | |||
| 37 | 37 | 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 | 55 | // ConfirmGate wraps a Confirmer with session-scoped memory for the | |
| 41 | 56 | // "allow, always" decisions. Once the user picks RememberTool on a | |
| 42 | 57 | // given tool name, the gate short-circuits subsequent calls of that | |
| @@ -70,6 +85,12 @@ func NewConfirmGate(inner Confirmer) *ConfirmGate { | |||
| 70 | 85 | // | |
| 71 | 86 | // A nil ConfirmGate always allows (treat as yolo mode). | |
| 72 | 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 | 94 | if g == nil { | |
| 74 | 95 | return true, "", nil | |
| 75 | 96 | } | |
| @@ -78,20 +99,22 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes | |||
| 78 | 99 | g.mu.Unlock() | |
| 79 | 100 | return true, "", nil | |
| 80 | 101 | } | |
| 81 | - | if g.allowedTool[toolName] { | |
| 102 | + | if g.allowedTool[call.Name] { | |
| 82 | 103 | g.mu.Unlock() | |
| 83 | 104 | return true, "", nil | |
| 84 | 105 | } | |
| 85 | - | g.mu.Unlock() | |
| 86 | - | ||
| 87 | - | g.mu.Lock() | |
| 88 | 106 | inner := g.inner | |
| 89 | 107 | g.mu.Unlock() | |
| 90 | 108 | if inner == nil { | |
| 91 | 109 | 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 | 119 | g.mu.Lock() | |
| 97 | 120 | if decision.Allow { | |
| @@ -99,7 +122,7 @@ func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMes | |||
| 99 | 122 | g.allowAll = true | |
| 100 | 123 | } | |
| 101 | 124 | if decision.RememberTool { | |
| 102 | - | g.allowedTool[toolName] = true | |
| 125 | + | g.allowedTool[call.Name] = true | |
| 103 | 126 | } | |
| 104 | 127 | } | |
| 105 | 128 | g.mu.Unlock() | |
| @@ -37,6 +37,21 @@ type Confirmer interface { | |||||
| 37 | Confirm(toolName string, preview string) ConfirmDecision | 37 | 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 the | 55 | // ConfirmGate wraps a Confirmer with session-scoped memory for the | ||
| 41 | // "allow, always" decisions. Once the user picks RememberTool on a | 56 | // "allow, always" decisions. Once the user picks RememberTool on a | ||
| 42 | // given tool name, the gate short-circuits subsequent calls of that | 57 | // 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, "", nil | 95 | 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, "", nil | 100 | 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, "", nil | 104 | return true, "", nil | ||
| 84 | } | 105 | } | ||
| 85 | - | g.mu.Unlock() | |||
| 86 | - | ||||
| 87 | - | g.mu.Lock() | |||
| 88 | inner := g.inner | 106 | 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", nil | 109 | 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 = true | 122 | g.allowAll = true | ||
| 100 | } | 123 | } | ||
| 101 | if decision.RememberTool { | 124 | if decision.RememberTool { | ||
| 102 | - | g.allowedTool[toolName] = true | 125 | + | 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 { | |||
| 26 | 26 | 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 | 56 | func TestConfirmGateNilAllowsEverything(t *testing.T) { | |
| 30 | 57 | var g *ConfirmGate | |
| 31 | 58 | allow, reason, args := g.Check("bash", "rm -rf /") | |
| @@ -26,6 +26,33 @@ func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision { | |||||
| 26 | return d | 26 | 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 *ConfirmGate | 57 | 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 { | |||
| 25 | 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 | 35 | // ToolResult is the outcome of Tool.Execute. | |
| 29 | 36 | type ToolResult struct { | |
| 30 | 37 | // 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 ( | |||
| 212 | 212 | ||
| 213 | 213 | // ToolCallView is a pending tool invocation plus optional result. | |
| 214 | 214 | 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 | |
| 221 | 222 | ||
| 222 | 223 | // Streaming is true while the model is still typing the tool | |
| 223 | 224 | // 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 | 823 | // when the overlay disappears. | |
| 823 | 824 | ||
| 824 | 825 | // 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 | |
| 827 | 828 | // 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 == "" { | |
| 830 | 831 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | |
| 831 | 832 | // Pad the live preview up to the call's high-water height so | |
| 832 | 833 | // 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 | 876 | // Finished tool call with no body: just the labelled top edge | |
| 871 | 877 | // directly closed by the bottom. Avoids a blank interior row | |
| 872 | 878 | // for no-output tools. | |
| 873 | - | if tc.Result == "" { | |
| 879 | + | if bodyText == "" { | |
| 874 | 880 | if v.FlatTools || v.CompactMode { | |
| 875 | 881 | if v.CompactMode { | |
| 876 | 882 | lines = append(lines, compactToolBlank(v.Theme, width)) | |
| @@ -900,7 +906,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 900 | 906 | lines = append(lines, compactToolBlank(v.Theme, width)) | |
| 901 | 907 | } | |
| 902 | 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 | 910 | for _, l := range v.collapseToolBody(body, false) { | |
| 905 | 911 | _, stripped := parseImageFootprint(l) | |
| 906 | 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 | 919 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 914 | 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 | 922 | for _, l := range v.collapseToolBody(body, false) { | |
| 917 | 923 | imgCells, stripped := parseImageFootprint(l) | |
| 918 | 924 | 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 string | 215 | + | ID string |
| 216 | - | Name string | 216 | + | Name string |
| 217 | - | Args string // rendered argument summary | 217 | + | Args string // rendered argument summary |
| 218 | - | Result string // rendered result preview (truncated) | 218 | + | Preview string // side-effect-free result shown before confirmation |
| 219 | - | Error bool | 219 | + | Result string // rendered result preview (truncated) |
| 220 | - | Done bool | 220 | + | Error bool |
| 221 | + | Done bool | |||
| 221 | 222 | ||||
| 222 | // Streaming is true while the model is still typing the tool | 223 | // Streaming is true while the model is still typing the tool | ||
| 223 | // call's JSON arguments. The TUI renders a live preview of any | 224 | // 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 until | 825 | // Live body (write/edit): keep the streamed preview visible until | ||
| 825 | - | // a real tool result arrives. The provider can finish the tool_use | 826 | + | // a proposed or real tool result arrives. The provider can finish the |
| 826 | - | // JSON before zot has executed the tool, so keying this on | 827 | + | // tool_use JSON before zot has executed the tool, so keying this on |
| 827 | // tc.Streaming makes write/edit boxes collapse for a moment between | 828 | // 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 so | 832 | // 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 the | 833 | // 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 edge | 876 | // Finished tool call with no body: just the labelled top edge | ||
| 871 | // directly closed by the bottom. Avoids a blank interior row | 877 | // 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) { | |||
| 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 | 101 | func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) { | |
| 78 | 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 | 103 | 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{ | ||