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