v0.1.62 380eca9

zotext.go RawHistoryFolder

1// Package zotext is the Go SDK for writing zot extensions.
2//
3// An extension is a subprocess that talks to zot over its stdin/stdout
4// in newline-delimited JSON. This package wraps the wire format so
5// extension authors can write straightforward Go without reimplementing
6// the protocol.
7//
8// Minimal example (registers /hello and replies with a static prompt):
9//
10//	package main
11//
12//	import "github.com/patriceckhart/zot/pkg/zotext"
13//
14//	func main() {
15//	    ext := zotext.New("hello", "1.0.0")
16//	    ext.Command("hello", "say hi", func(args string) zotext.Response {
17//	        return zotext.Prompt("Greet me in three different languages.")
18//	    })
19//	    ext.Run()
20//	}
21//
22// Build it, drop the binary + an extension.json next to it under
23// `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.
24//
25// The same wire format also has reference clients in TypeScript and
26// Python under examples/extensions/. Use whichever language fits.
27package zotext
28
29import (
30	"bufio"
31	"encoding/base64"
32	"encoding/json"
33	"fmt"
34	"io"
35	"os"
36	"sync"
37
38	"github.com/patriceckhart/zot/internal/extproto"
39)
40
41func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
42
43// CommandHandler is invoked when the user runs the extension's
44// registered slash command. args is everything the user typed after
45// the command name (already trimmed). Return a Response describing
46// what zot should do next.
47type CommandHandler func(args string) Response
48
49// ToolHandler is invoked when the LLM calls a tool the extension
50// registered. args is the raw JSON object the model produced; the
51// handler is responsible for parsing/validating it. Return a
52// ToolResult describing what zot should send back to the model.
53type ToolHandler func(args json.RawMessage) ToolResult
54
55// EventHandler is called for each lifecycle event the extension
56// subscribed to via Subscribe. The handler is invoked synchronously
57// on the read goroutine; keep it quick or hand off to your own
58// worker.
59type EventHandler func(ev Event)
60
61// Event is a lifecycle notification from zot. The fields populated
62// depend on Name (the host's event_name string):
63//
64//	session_start    : (no extra fields)
65//	turn_start       : Step
66//	turn_end         : Stop, optional Error
67//	tool_call        : ToolID, ToolName, ToolArgs
68//	assistant_message: Text
69type Event struct {
70	Name string
71
72	Step  int
73	Stop  string
74	Error string
75
76	ToolID   string
77	ToolName string
78	ToolArgs json.RawMessage
79
80	Text string
81}
82
83// InterceptHandler decides whether a tool call may proceed. Return
84// (allow=true) to permit, (allow=false, reason) to refuse. The
85// reason is shown to the model as the tool's error text.
86//
87// This is the original 2-value handler. For richer control (arg
88// rewrites, turn_start / assistant_message interception, etc.)
89// register via InterceptToolCallX, InterceptTurnStart, or
90// InterceptAssistantMessage.
91type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string)
92
93// ToolCallDecision is the richer reply an InterceptToolCallHandler
94// can return. Zero value means "allow, pass through".
95type ToolCallDecision struct {
96	// Block refuses the call. The model sees Reason as the tool's
97	// error text and typically proposes a different action.
98	Block bool
99	// Reason is the refusal text shown to the model on Block, or a
100	// logged note when passing through (optional).
101	Reason string
102	// ModifiedArgs, when non-nil and Block is false, replaces the
103	// JSON args the tool actually runs with. Lets guards redact /
104	// augment / patch the model's request without rewriting the
105	// transcript. Must encode to a JSON object.
106	ModifiedArgs json.RawMessage
107}
108
109// ToolCallHandler is the richer form of an interceptor. Use this
110// when you want to rewrite args instead of only blocking.
111type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision
112
113// TurnStartDecision controls whether the next model call runs. Zero
114// value means "allow".
115type TurnStartDecision struct {
116	Block  bool
117	Reason string
118}
119
120// TurnStartHandler is called before every turn's model call. Return
121// Block=true with Reason to abort the turn (shown to the user as a
122// status line). Useful for rate-limiting and business-hour gates.
123type TurnStartHandler func(step int) TurnStartDecision
124
125// AssistantMessageDecision controls the final assistant-text
126// rendering. Zero value means "allow, show as-is".
127type AssistantMessageDecision struct {
128	// Block suppresses the message from the user entirely. The
129	// transcript still records the model's original output so the
130	// model sees what it said on the next turn.
131	Block bool
132	// Reason is logged when blocking (optional).
133	Reason string
134	// ReplaceText, when non-empty and Block is false, is what the
135	// user sees. The model's original text still lives in the
136	// transcript.
137	ReplaceText string
138}
139
140// AssistantMessageHandler is called after the model's final text is
141// assembled but before it's shown. Use it to scrub secrets, expand
142// templates, enforce tone, or suppress responses entirely.
143type AssistantMessageHandler func(text string) AssistantMessageDecision
144
145// ToolResult is the extension's reply to a tool invocation. Build
146// one with TextResult, ImageResult, or directly when you need to
147// combine multiple blocks.
148type ToolResult struct {
149	Content []ToolContent
150	IsError bool
151}
152
153// ToolContent is one block of tool output. Either Text is set, or
154// MimeType+Data (base64-encoded). Use the Text/Image helpers below.
155type ToolContent struct {
156	Type     string // "text" | "image"
157	Text     string
158	MimeType string
159	Data     string // base64
160}
161
162// Text returns a text content block.
163func Text(s string) ToolContent { return ToolContent{Type: "text", Text: s} }
164
165// Image returns an image content block. data must already be
166// base64-encoded; use ImageBytes to encode raw bytes.
167func Image(mimeType, base64Data string) ToolContent {
168	return ToolContent{Type: "image", MimeType: mimeType, Data: base64Data}
169}
170
171// ImageBytes returns an image content block from raw bytes,
172// encoding them to base64 for the wire.
173func ImageBytes(mimeType string, data []byte) ToolContent {
174	return ToolContent{Type: "image", MimeType: mimeType, Data: base64Encode(data)}
175}
176
177// TextResult returns a tool result with one text block, success.
178func TextResult(s string) ToolResult { return ToolResult{Content: []ToolContent{Text(s)}} }
179
180// TextErrorResult returns a tool result with one text block, marked
181// as an error to the model.
182func TextErrorResult(s string) ToolResult {
183	return ToolResult{Content: []ToolContent{Text(s)}, IsError: true}
184}
185
186// Response tells zot how to react to a command invocation. Construct
187// one with Prompt(), Insert(), Display(), or Noop().
188type Panel struct {
189	ID     string
190	Title  string
191	Lines  []string
192	Footer string
193}
194
195type Response struct {
196	Action    string // "prompt", "insert", "display", "open_panel", "noop"
197	Prompt    string
198	Insert    string
199	Display   string
200	OpenPanel *Panel
201	Error     string
202}
203
204// Prompt returns a Response that submits text as a fresh user message
205// to the agent (running it through the model loop as if the user had
206// typed and pressed enter).
207func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} }
208
209// Insert returns a Response that drops text into the editor at the
210// cursor without submitting.
211func Insert(text string) Response { return Response{Action: "insert", Insert: text} }
212
213// Display returns a Response that adds a one-shot styled note to the
214// chat without invoking the model. Useful for showing a result without
215// burning tokens.
216func Display(text string) Response { return Response{Action: "display", Display: text} }
217
218// OpenPanel returns a Response that opens an interactive extension-owned
219// panel inside zot.
220func OpenPanel(id, title string, lines []string, footer string) Response {
221	return Response{Action: "open_panel", OpenPanel: &Panel{ID: id, Title: title, Lines: lines, Footer: footer}}
222}
223
224// Noop returns a Response that signals "I handled it, no UI change".
225// Use after pushing your own state or notifications.
226func Noop() Response { return Response{Action: "noop"} }
227
228// Errorf returns a Response that surfaces the error in the chat as a
229// red status line.
230func Errorf(format string, args ...any) Response {
231	return Response{Action: "noop", Error: fmt.Sprintf(format, args...)}
232}
233
234// Extension is one zot extension. Construct with New, register
235// commands, then call Run.
236type Extension struct {
237	name    string
238	version string
239
240	in      io.Reader
241	out     io.Writer
242	stderr  io.Writer
243	writeMu sync.Mutex
244
245	mu            sync.Mutex
246	commands      map[string]CommandHandler
247	descriptions  []descTuple // ordered so register frames arrive in registration order
248	tools         map[string]ToolHandler
249	toolDefs      []toolDef // ordered so register frames arrive in registration order
250	eventHandlers map[string]EventHandler
251	eventNames    []string // declared subscription order
252
253	interceptTool      InterceptHandler
254	interceptToolRich  ToolCallHandler
255	interceptOn        bool
256	interceptTurn      TurnStartHandler
257	interceptAssistant AssistantMessageHandler
258	panelKeys          map[string]func(key, text string)
259	panelCloses        map[string]func()
260
261	// Caps reported in the hello frame.
262	caps []string
263
264	// hostInfo is filled in once HelloAck arrives.
265	host HostInfo
266}
267
268type descTuple struct {
269	name, desc string
270}
271
272type toolDef struct {
273	name        string
274	description string
275	schema      json.RawMessage
276}
277
278// HostInfo is what the host (zot) tells us in HelloAck. Useful for
279// extensions that want to behave differently per provider.
280type HostInfo struct {
281	ProtocolVersion int
282	ZotVersion      string
283	Provider        string
284	Model           string
285	CWD             string
286	ExtensionDir    string
287	DataDir         string
288}
289
290// New constructs an Extension with the given identifier. name should
291// match the name field in extension.json.
292func New(name, version string) *Extension {
293	return &Extension{
294		name:          name,
295		version:       version,
296		in:            os.Stdin,
297		out:           os.Stdout,
298		stderr:        os.Stderr,
299		commands:      map[string]CommandHandler{},
300		tools:         map[string]ToolHandler{},
301		eventHandlers: map[string]EventHandler{},
302		panelKeys:     map[string]func(key, text string){},
303		panelCloses:   map[string]func(){},
304		caps:          []string{"commands", "tools", "events", "panels"},
305	}
306}
307
308// Host returns the HostInfo received during the hello handshake.
309// Returns the zero value if Run hasn't started yet.
310func (e *Extension) Host() HostInfo { return e.host }
311
312// Logf writes a line to the extension's stderr, which zot captures to
313// $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything
314// you print to stdout would corrupt the JSON wire protocol.
315func (e *Extension) Logf(format string, args ...any) {
316	fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...)
317}
318
319// OnPanelKey registers callbacks for panel key + close events.
320func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onClose func()) {
321	e.mu.Lock()
322	defer e.mu.Unlock()
323	if onKey != nil {
324		e.panelKeys[panelID] = onKey
325	}
326	if onClose != nil {
327		e.panelCloses[panelID] = onClose
328	}
329}
330
331// RenderPanel pushes a fresh frame for panelID.
332func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) {
333	_ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer})
334}
335
336// ClosePanel tells zot to close panelID.
337func (e *Extension) ClosePanel(panelID string) {
338	_ = e.send(extproto.PanelCloseFromExt{Type: "panel_close", PanelID: panelID})
339}
340
341// Command registers a slash-command handler. Call this BEFORE Run().
342// Once Run is going, when the user runs /name in zot, fn is invoked
343// with the remaining args.
344//
345// Naming conflicts with built-in commands (e.g. /help) are silently
346// shadowed by the built-in; check the user's "ext logs" if a command
347// you registered isn't taking effect.
348func (e *Extension) Command(name, description string, fn CommandHandler) {
349	e.mu.Lock()
350	e.commands[name] = fn
351	e.descriptions = append(e.descriptions, descTuple{name: name, desc: description})
352	e.mu.Unlock()
353}
354
355// Tool registers an LLM-callable tool. schema is a JSON Schema
356// object describing the tool's args (the same shape Anthropic /
357// OpenAI accept). Call this BEFORE Run(); zot folds extension tools
358// into the agent's registry once the extension's ready frame fires.
359//
360// Naming conflicts with built-in tools (read, write, edit, bash,
361// skill) are silently shadowed by the built-in.
362func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
363	e.mu.Lock()
364	e.tools[name] = fn
365	e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema})
366	e.mu.Unlock()
367}
368
369// On subscribes to a lifecycle event. fn is called for each
370// notification; the same name can only have one handler (later
371// registrations replace earlier ones). Recognised names:
372// session_start, turn_start, turn_end, tool_call,
373// assistant_message.
374func (e *Extension) On(name string, fn EventHandler) {
375	e.mu.Lock()
376	if _, exists := e.eventHandlers[name]; !exists {
377		e.eventNames = append(e.eventNames, name)
378	}
379	e.eventHandlers[name] = fn
380	e.mu.Unlock()
381}
382
383// InterceptToolCall registers a guard that runs immediately before
384// each tool call. Returning (false, reason) refuses the call; the
385// model sees reason as the tool error text. Multiple extensions can
386// install interceptors; if any one refuses, the call is blocked.
387//
388// Use this to build permission gates: refuse `bash` calls containing
389// `rm -rf`, ask the user for confirmation on dangerous patterns,
390// audit-log every call, etc.
391//
392// For the richer form (arg rewrites, structured decisions) use
393// InterceptToolCallX.
394func (e *Extension) InterceptToolCall(fn InterceptHandler) {
395	e.mu.Lock()
396	e.interceptTool = fn
397	e.interceptOn = true
398	e.mu.Unlock()
399}
400
401// InterceptToolCallX is the richer variant. Return a ToolCallDecision
402// to block, allow, or rewrite args mid-flight. If both
403// InterceptToolCall and InterceptToolCallX are set, the X form wins.
404func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
405	e.mu.Lock()
406	e.interceptToolRich = fn
407	e.interceptOn = true
408	e.mu.Unlock()
409}
410
411// InterceptTurnStart registers a guard that runs before every turn's
412// model call. Return Block=true with Reason to abort the turn.
413// Useful for deny-by-default gates and usage quotas.
414func (e *Extension) InterceptTurnStart(fn TurnStartHandler) {
415	e.mu.Lock()
416	e.interceptTurn = fn
417	e.mu.Unlock()
418}
419
420// InterceptAssistantMessage registers a guard that runs after the
421// model's final text is assembled but before it's shown. Return
422// Block=true to suppress entirely, or ReplaceText to rewrite what
423// the user sees. The model's original output stays in the transcript.
424func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) {
425	e.mu.Lock()
426	e.interceptAssistant = fn
427	e.mu.Unlock()
428}
429
430// Notify pushes an info-level status note into zot's chat without
431// requiring a slash command from the user.
432func (e *Extension) Notify(level, message string) {
433	_ = e.send(extproto.NotifyFromExt{
434		Type:    "notify",
435		Level:   level,
436		Message: message,
437	})
438}
439
440// Run starts the protocol loop. Blocks until stdin closes (zot has
441// shut us down). Returns the first fatal error, or nil on clean exit.
442func (e *Extension) Run() error {
443	// Send hello, then re-announce all commands (covers Command calls
444	// made before Run, and is also fine for those made after via
445	// Command()'s direct send).
446	if err := e.send(extproto.HelloFromExt{
447		Type:         "hello",
448		Name:         e.name,
449		Version:      e.version,
450		Capabilities: e.caps,
451	}); err != nil {
452		return err
453	}
454	e.mu.Lock()
455	descs := append([]descTuple(nil), e.descriptions...)
456	toolDefs := append([]toolDef(nil), e.toolDefs...)
457	eventNames := append([]string(nil), e.eventNames...)
458	interceptTool := e.interceptOn
459	interceptTurn := e.interceptTurn != nil
460	interceptAsst := e.interceptAssistant != nil
461	e.mu.Unlock()
462	for _, d := range descs {
463		_ = e.send(extproto.RegisterCommandFromExt{
464			Type:        "register_command",
465			Name:        d.name,
466			Description: d.desc,
467		})
468	}
469	for _, td := range toolDefs {
470		_ = e.send(extproto.RegisterToolFromExt{
471			Type:        "register_tool",
472			Name:        td.name,
473			Description: td.description,
474			Schema:      td.schema,
475		})
476	}
477	var intercepts []string
478	if interceptTool {
479		intercepts = append(intercepts, "tool_call")
480	}
481	if interceptTurn {
482		intercepts = append(intercepts, "turn_start")
483	}
484	if interceptAsst {
485		intercepts = append(intercepts, "assistant_message")
486	}
487	if len(eventNames) > 0 || len(intercepts) > 0 {
488		_ = e.send(extproto.SubscribeFromExt{
489			Type:      "subscribe",
490			Events:    eventNames,
491			Intercept: intercepts,
492		})
493	}
494	// Sentinel: tells the host that all initial registrations have
495	// been flushed and the agent registry can be built. Never block
496	// on this; if the host doesn't act on it, registrations still
497	// land in time for the typical use case.
498	_ = e.send(extproto.ReadyFromExt{Type: "ready"})
499
500	scanner := bufio.NewScanner(e.in)
501	scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
502	for scanner.Scan() {
503		line := scanner.Bytes()
504		var frame extproto.Frame
505		if err := json.Unmarshal(line, &frame); err != nil {
506			e.Logf("malformed frame from host: %v", err)
507			continue
508		}
509		switch frame.Type {
510		case "hello_ack":
511			var ack extproto.HelloAckFromHost
512			if err := json.Unmarshal(line, &ack); err == nil {
513				e.host = HostInfo{
514					ProtocolVersion: ack.ProtocolVersion,
515					ZotVersion:      ack.ZotVersion,
516					Provider:        ack.Provider,
517					Model:           ack.Model,
518					CWD:             ack.CWD,
519					ExtensionDir:    ack.ExtensionDir,
520					DataDir:         ack.DataDir,
521				}
522			}
523		case "command_invoked":
524			var ci extproto.CommandInvokedFromHost
525			if err := json.Unmarshal(line, &ci); err != nil {
526				continue
527			}
528			e.mu.Lock()
529			fn := e.commands[ci.Name]
530			e.mu.Unlock()
531			if fn == nil {
532				e.respond(ci.ID, Errorf("no handler for /%s", ci.Name))
533				continue
534			}
535			// Run the handler on its own goroutine so a slow handler
536			// doesn't block subsequent commands. Order isn't promised.
537			go func(id string, fn CommandHandler, args string) {
538				defer func() {
539					if r := recover(); r != nil {
540						e.respond(id, Errorf("panic: %v", r))
541					}
542				}()
543				resp := fn(args)
544				e.respond(id, resp)
545			}(ci.ID, fn, ci.Args)
546		case "tool_call":
547			var tc extproto.ToolCallFromHost
548			if err := json.Unmarshal(line, &tc); err != nil {
549				continue
550			}
551			e.mu.Lock()
552			fn := e.tools[tc.Name]
553			e.mu.Unlock()
554			if fn == nil {
555				e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
556				continue
557			}
558			go func(id string, fn ToolHandler, args json.RawMessage) {
559				defer func() {
560					if r := recover(); r != nil {
561						e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
562					}
563				}()
564				res := fn(args)
565				e.respondTool(id, res)
566			}(tc.ID, fn, tc.Args)
567		case "event":
568			var ef extproto.EventFromHost
569			if err := json.Unmarshal(line, &ef); err != nil {
570				continue
571			}
572			e.mu.Lock()
573			handler := e.eventHandlers[ef.Event]
574			e.mu.Unlock()
575			if handler != nil {
576				func() {
577					defer func() {
578						if r := recover(); r != nil {
579							e.Logf("event %s handler panicked: %v", ef.Event, r)
580						}
581					}()
582					handler(Event{
583						Name: ef.Event, Step: ef.Step, Stop: ef.Stop,
584						Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
585						ToolArgs: ef.ToolArgs, Text: ef.Text,
586					})
587				}()
588			}
589		case "event_intercept":
590			var ei extproto.EventInterceptFromHost
591			if err := json.Unmarshal(line, &ei); err != nil {
592				continue
593			}
594			go e.dispatchIntercept(ei)
595		case "panel_key":
596			var pk extproto.PanelKeyFromHost
597			if err := json.Unmarshal(line, &pk); err != nil {
598				continue
599			}
600			e.mu.Lock()
601			h := e.panelKeys[pk.PanelID]
602			e.mu.Unlock()
603			if h != nil {
604				go h(pk.Key, pk.Text)
605			}
606		case "panel_close":
607			var pc extproto.PanelCloseFromHost
608			if err := json.Unmarshal(line, &pc); err != nil {
609				continue
610			}
611			e.mu.Lock()
612			h := e.panelCloses[pc.PanelID]
613			e.mu.Unlock()
614			if h != nil {
615				go h()
616			}
617		case "shutdown":
618			_ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
619			return nil
620		default:
621			e.Logf("unknown frame type %q", frame.Type)
622		}
623	}
624	return scanner.Err()
625}
626
627// respond serialises a CommandResponseFromExt for the given id.
628func (e *Extension) respond(id string, r Response) {
629	if r.Action == "" {
630		r.Action = "noop"
631	}
632	var panel *extproto.PanelSpec
633	if r.OpenPanel != nil {
634		panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer}
635	}
636	_ = e.send(extproto.CommandResponseFromExt{
637		Type:      "command_response",
638		ID:        id,
639		Action:    r.Action,
640		Prompt:    r.Prompt,
641		Insert:    r.Insert,
642		Display:   r.Display,
643		OpenPanel: panel,
644		Error:     r.Error,
645	})
646}
647
648// respondTool serialises a ToolResultFromExt for the given id.
649func (e *Extension) respondTool(id string, r ToolResult) {
650	blocks := make([]extproto.ContentBlock, 0, len(r.Content))
651	for _, c := range r.Content {
652		blocks = append(blocks, extproto.ContentBlock{
653			Type:     c.Type,
654			Text:     c.Text,
655			MimeType: c.MimeType,
656			Data:     c.Data,
657		})
658	}
659	_ = e.send(extproto.ToolResultFromExt{
660		Type:    "tool_result",
661		ID:      id,
662		Content: blocks,
663		IsError: r.IsError,
664	})
665}
666
667// send marshals v + LF and writes it under a mutex (so concurrent
668// goroutines don't interleave bytes on stdout).
669func (e *Extension) send(v any) error {
670	b, err := extproto.Encode(v)
671	if err != nil {
672		return err
673	}
674	e.writeMu.Lock()
675	defer e.writeMu.Unlock()
676	_, err = e.out.Write(b)
677	return err
678}
679
680// dispatchIntercept runs the per-event handler (tool_call / turn_start
681// / assistant_message) on its own goroutine, catches panics, and
682// always emits exactly one event_intercept_response. Called from the
683// Run loop.
684func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
685	defer func() {
686		if r := recover(); r != nil {
687			_ = e.send(extproto.EventInterceptResponseFromExt{
688				Type:   "event_intercept_response",
689				ID:     ei.ID,
690				Block:  true,
691				Reason: fmt.Sprintf("intercept panic: %v", r),
692			})
693		}
694	}()
695
696	resp := extproto.EventInterceptResponseFromExt{
697		Type: "event_intercept_response",
698		ID:   ei.ID,
699	}
700
701	switch ei.Event {
702	case "tool_call":
703		e.mu.Lock()
704		rich := e.interceptToolRich
705		plain := e.interceptTool
706		e.mu.Unlock()
707		if rich != nil {
708			d := rich(ei.ToolName, ei.ToolArgs)
709			resp.Block = d.Block
710			resp.Reason = d.Reason
711			if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) {
712				resp.ModifiedArgs = d.ModifiedArgs
713			}
714		} else if plain != nil {
715			allow, reason := plain(ei.ToolName, ei.ToolArgs)
716			resp.Block = !allow
717			resp.Reason = reason
718		}
719	case "turn_start":
720		e.mu.Lock()
721		fn := e.interceptTurn
722		e.mu.Unlock()
723		if fn != nil {
724			d := fn(ei.Step)
725			resp.Block = d.Block
726			resp.Reason = d.Reason
727		}
728	case "assistant_message":
729		e.mu.Lock()
730		fn := e.interceptAssistant
731		e.mu.Unlock()
732		if fn != nil {
733			d := fn(ei.Text)
734			resp.Block = d.Block
735			resp.Reason = d.Reason
736			if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text {
737				resp.ReplaceText = d.ReplaceText
738			}
739		}
740	}
741
742	_ = e.send(resp)
743}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close