1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
// Package ui decides where the agent's human-facing output goes, and — when a
// front end wants events rather than text — carries those events as a Sink.
//
// Two front ends share the same engine and the same tools:
//
// - the terminal REPL (internal/agent): Sink stays nil, Out is os.Stdout,
// and the tools print their own 🛠️/📄/📝 lines exactly as in part 10;
// - the ACP façade (internal/acp): stdout belongs to JSON-RPC — the spec says
// the agent "MUST NOT write anything to its stdout that is not a valid ACP
// message" — so main.go moves Out to os.Stderr, and the façade installs a
// Sink that turns every event into a session/update notification.
//
// The package holds no protocol knowledge: the event names below are bob's
// (a tool starts, a tool ends, may I run this?), and internal/acp is the one
// place that translates them into ACP messages.
package ui
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
)
// Out is where human-facing text goes when no Sink is active: the banner, the
// warnings, the watchdog message. os.Stdout for the terminal REPL; main.go
// moves it to os.Stderr in ACP mode, so everything already written for the
// terminal stays readable — in the editor's agent logs instead of the screen.
var Out io.Writer = os.Stdout
// ToolEvent describes one tool call, in bob's vocabulary.
type ToolEvent struct {
ID string // "call_1" — unique within the process, see NextCallID
Tool string // the tool's name: "bash", "read_file", …
Title string // one line for the client's UI: "bash: ls -1"
Kind string // how a client should render it: "execute", "read", "edit"
Input map[string]any // the raw input, for the client to display
Path string // the file touched (absolute), "" when none
}
// ToolResult is what a tool call ended with.
type ToolResult struct {
Output string // what goes back to the model (already truncated)
Failed bool // non-zero exit, refused edit, rejected permission…
Diff *Diff // the file change, when there is one a client can render
}
// Diff carries a file change for clients able to show a real diff (ACP has a
// dedicated content type for it — that is how Zed renders edits).
type Diff struct {
Path string // absolute
OldText string // ignored when Created
NewText string
Created bool // the file did not exist before
}
// Sink receives the events of a turn. nil (the default) means the terminal
// front end: the tools print themselves and nobody asks for permission.
type Sink interface {
// Text is a chunk of the model's streamed answer.
Text(text string)
// ToolStart announces a call before anything runs (ACP: status "pending").
ToolStart(ev ToolEvent)
// Allow asks whether the call may run. The terminal front end has no
// gatekeeper and would always say yes; a client shows the user a dialog.
// ctx is the turn's context: a cancelled turn abandons the question.
Allow(ctx context.Context, ev ToolEvent) bool
// ToolRunning reports that the call was allowed and is executing.
ToolRunning(id string)
// ToolEnd reports the outcome, output included.
ToolEnd(id string, res ToolResult)
// WorkDir is the session's working directory ("" = the process's own):
// where bash runs and what relative file paths resolve against.
WorkDir() string
}
// The active sink is set once per turn by the ACP façade and read by the tools
// from Genkit's goroutines — hence the lock, not a plain variable.
var (
mu sync.RWMutex
active Sink
)
// SetActive installs (or, with nil, removes) the sink for the current turn.
func SetActive(s Sink) {
mu.Lock()
active = s
mu.Unlock()
}
// ActiveSink returns the sink of the current turn, nil for the terminal.
func ActiveSink() Sink {
mu.RLock()
defer mu.RUnlock()
return active
}
// callID numbers the tool calls of the process. Never reset: a client
// correlates tool_call and tool_call_update by this ID, and reusing one across
// sessions would merge two calls in its UI.
var callID atomic.Int64
// NextCallID returns "call_1", "call_2", …
func NextCallID() string {
return fmt.Sprintf("call_%d", callID.Add(1))
}
// Resolve makes a relative path absolute against the active session's working
// directory. With no sink — the terminal REPL — the path is returned as-is:
// part 10's behaviour, relative to the process's directory, unchanged.
func Resolve(path string) string {
s := ActiveSink()
if s == nil || filepath.IsAbs(path) || s.WorkDir() == "" {
return path
}
return filepath.Join(s.WorkDir(), path)
}
|