// 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) }