bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

💾 Saved. d722711 · on d72271127802973540c648bfb372176cdaaa8e4f · k33g · 6h ago
ui.go · 120 lines · 4.5 KBGo Blame HistoryRaw
  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)
}