v0.17.0 949637b

interactive_host.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"strings"
9	"time"
10
11	"github.com/bnema/zut/packages/agent/extproto"
12	"github.com/bnema/zut/packages/agent/internal/orchestration"
13	"github.com/bnema/zut/packages/agent/subagents"
14	"github.com/bnema/zut/packages/core"
15	"github.com/bnema/zut/packages/provider"
16	"github.com/bnema/zut/packages/tui"
17)
18
19func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
20	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
21	if err != nil {
22		i.mu.Lock()
23		i.statusErr = "extension /" + name + ": " + err.Error()
24		i.statusOK = ""
25		i.mu.Unlock()
26		i.invalidate()
27		return
28	}
29	if resp.Error != "" {
30		i.mu.Lock()
31		i.statusErr = "extension /" + name + ": " + resp.Error
32		i.statusOK = ""
33		i.mu.Unlock()
34		i.invalidate()
35		return
36	}
37	switch resp.Action {
38	case "open_panel":
39		if resp.OpenPanel != nil {
40			extName := name
41			if i.cfg.Extensions != nil {
42				if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
43					extName = owner
44				}
45			}
46			i.OpenPanel(extName, *resp.OpenPanel)
47		}
48	case "prompt":
49		if strings.TrimSpace(resp.Prompt) == "" {
50			return
51		}
52		i.startTurn(i.runCtx, resp.Prompt)
53	case "insert":
54		i.ed.Insert(resp.Insert)
55		i.invalidate()
56	case "display":
57		i.appendExtensionNote(name, resp.Display, "info")
58	case "noop", "":
59		// nothing
60	default:
61		i.mu.Lock()
62		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
63		i.mu.Unlock()
64		i.invalidate()
65	}
66}
67func (i *Interactive) appendExtensionNote(extName, msg, level string) {
68	if msg == "" {
69		return
70	}
71	i.mu.Lock()
72	defer i.mu.Unlock()
73	color := i.cfg.Theme.Muted
74	switch level {
75	case "warn":
76		color = i.cfg.Theme.Warning
77	case "error":
78		color = i.cfg.Theme.Error
79	case "success":
80		color = i.cfg.Theme.Tool
81	}
82	prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
83	for _, line := range strings.Split(msg, "\n") {
84		i.statusOK = "" // clear any stale ok
85		i.statusErr = ""
86		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
87	}
88	// Extension slash commands complete asynchronously. If their result is
89	// displayed while confirmation is waiting, keep Esc assigned to the note
90	// until it is dismissed rather than letting it answer the tool call.
91	i.confirmDialog.Blur()
92}
93func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
94	i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
95}
96func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
97	if ag == nil {
98		return
99	}
100	i.agentMu.Lock()
101	defer i.agentMu.Unlock()
102	i.mu.Lock()
103	i.prepareReplacementAgentLocked(ag)
104	i.compactContinuation = decodeCompactHandoff(compactHandoff)
105	i.agent = ag
106	i.cfg.Provider = providerName
107	i.cfg.Model = model
108	i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
109	i.cumUsage = ag.Cost()
110	last := ag.LastTurnUsage()
111	i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
112	if len(i.view.Messages) > initialResumeTailLimit {
113		i.view.TailLimit = initialResumeTailLimit
114	} else {
115		i.view.TailLimit = 0
116	}
117	i.view.InvalidateRenderCache()
118	i.toolCalls = map[string]*tui.ToolCallView{}
119	i.toolOrder = nil
120	i.toolGate = map[string]int{}
121	i.helpBlock = nil
122	i.extNotes = nil
123	i.parkedTurn = 0
124	i.parkedTotal = 0
125	i.mu.Unlock()
126	i.invalidate()
127}
128func (i *Interactive) SetSubagentSessionScope(_ string) {
129	i.invalidate()
130}
131func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
132	i.applyChangedCWD(ag, provider, model, cwd, nil)
133}
134func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
135	i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
136}
137func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
138	home, _ := os.UserHomeDir()
139	profiles, _ := subagents.Discover(cwd, home)
140	subagentsAddendum := subagents.SystemPromptAddendum(profiles)
141
142	i.agentMu.Lock()
143	defer i.agentMu.Unlock()
144	i.mu.Lock()
145	i.prepareReplacementAgentLocked(ag)
146	i.resetCompactContinuationLocked()
147	i.agent = ag
148	i.cfg.CWD = cwd
149	i.cfg.SubagentsSystemAddendum = subagentsAddendum
150	i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
151	i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
152	i.view.StartupContextPaths = nil
153	i.view.InvalidateRenderCache()
154	if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
155		i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
156	}
157	// Re-report the working directory to the terminal so "new tab here"
158	// tracks the /cd change (OSC 7, see issue #38).
159	if i.cfg.Terminal != nil {
160		if seq := tui.ReportCWD(cwd); seq != "" {
161			_, _ = i.cfg.Terminal.Write([]byte(seq))
162		}
163	}
164	i.cfg.Provider = provider
165	i.cfg.Model = model
166	titleCancel := i.titleCancel
167	i.titleCancel = nil
168	i.titleVersion++
169	i.sessionTitle = ""
170	i.titleRealPromptSeen = false
171	i.titleGenerationStarted = false
172	i.writeTerminalTitleLocked("")
173	i.toolCalls = map[string]*tui.ToolCallView{}
174	i.toolOrder = nil
175	i.toolGate = map[string]int{}
176	i.helpBlock = nil
177	i.parkedTurn = 0
178	i.statusErr = ""
179	i.mu.Unlock()
180	if titleCancel != nil {
181		titleCancel()
182	}
183	i.fileSuggest.Reset()
184	i.fileSuggest.SetCWD(cwd)
185	i.invalidate()
186}
187func (i *Interactive) SubmitSlash(text string) {
188	i.mu.Lock()
189	i.doubleEscape.Reset()
190	i.mu.Unlock()
191	text = strings.TrimSpace(text)
192	if !strings.HasPrefix(text, "/") {
193		return
194	}
195	if slashCommandCancelsTurn(text) {
196		i.cancelAndWaitForIdle()
197	}
198	i.runSlash(i.runCtx, text)
199	i.invalidate()
200}
201func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
202	i.submitOrQueue(text, images, true)
203}
204func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
205	if cmd, ok := shellEscapeCommand(text); ok {
206		i.startShellEscape(i.runCtx, cmd)
207		return
208	}
209	i.mu.Lock()
210	if i.agent == nil {
211		i.statusErr = "not logged in. type /login first."
212		i.mu.Unlock()
213		i.invalidate()
214		return
215	}
216	i.mu.Unlock()
217	if userInput && i.cfg.EnsureMission != nil {
218		if err := i.cfg.EnsureMission(text); err != nil {
219			i.ReportError(fmt.Errorf("persist mission: %w", err))
220			return
221		}
222	}
223	// User input is an orchestration event, not merely a queue append. When
224	// the manager is dormant behind a sealed worker wave, the coordinator makes
225	// this the next manager turn and carries any completed worker summary with it.
226	if userInput && i.coordinatorAcceptsUserInput() {
227		if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 {
228			i.executeCoordinatorActions(actions)
229			return
230		}
231	}
232	i.maybeStartSessionTitle(i.runCtx, text)
233	i.mu.Lock()
234	if i.busy {
235		// Queue text only; images are dropped for queued prompts. Keep the
236		// interactive mutex held through Agent.QueueMessage so turn teardown
237		// cannot inspect the agent queue between the busy check and enqueue.
238		// Compaction uses the host queue because it has no active agent loop
239		// to drain Agent.QueueMessage entries.
240		var handoff json.RawMessage
241		var persistHandoff bool
242		if i.agent != nil && !i.compacting {
243			handoff, persistHandoff = i.resetCompactContinuationLocked()
244			i.agent.QueueMessage(text)
245		} else {
246			handoff, persistHandoff = i.resetCompactContinuationLocked()
247			i.queued = append(i.queued, text)
248		}
249		i.mu.Unlock()
250		if persistHandoff {
251			i.persistCompactHandoff(handoff)
252		}
253		i.invalidate()
254		return
255	}
256	i.mu.Unlock()
257	i.startTurnWithImages(i.runCtx, text, images)
258}
259func (i *Interactive) ChangelogVersion() string {
260	if i.changelogDialog != nil {
261		return i.changelogDialog.version
262	}
263	return ""
264}
265func (i *Interactive) CancelTurn() {
266	i.mu.Lock()
267	cancel := i.cancelTurn
268	i.mu.Unlock()
269	if cancel != nil {
270		i.cancelCoordinator()
271
272		i.mu.Lock()
273		handoff, persistHandoff := i.resetCompactContinuationLocked()
274		i.mu.Unlock()
275		cancel()
276		i.confirmDialog.CancelAll("turn cancelled")
277		if persistHandoff {
278			i.persistCompactHandoff(handoff)
279		}
280	}
281}
282func (i *Interactive) Insert(text string) {
283	i.ed.Insert(text)
284	i.invalidate()
285}
286func (i *Interactive) Display(extName, text string) {
287	i.appendExtensionNote(extName, text, "info")
288	i.invalidate()
289}
290func (i *Interactive) ReportError(err error) {
291	if err == nil {
292		return
293	}
294	i.mu.Lock()
295	i.statusOK = ""
296	i.statusErr = err.Error()
297	i.mu.Unlock()
298	i.invalidate()
299}
300func (i *Interactive) SetStatus(extName, key, level, text string) {
301	if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
302		return
303	}
304	i.mu.Lock()
305	if i.extStatuses == nil {
306		i.extStatuses = map[string]map[string]extensionStatus{}
307	}
308	if strings.TrimSpace(text) == "" {
309		if items := i.extStatuses[extName]; items != nil {
310			delete(items, key)
311			if len(items) == 0 {
312				delete(i.extStatuses, extName)
313			}
314		}
315	} else {
316		if i.extStatuses[extName] == nil {
317			i.extStatuses[extName] = map[string]extensionStatus{}
318		}
319		i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
320	}
321	i.mu.Unlock()
322	i.invalidate()
323}
324func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
325	if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
326		return
327	}
328	position = extproto.NormalizeWidgetPosition(position)
329	i.mu.Lock()
330	if i.extWidgets == nil {
331		i.extWidgets = map[string]map[string]extensionWidget{}
332	}
333	if i.extWidgets[extName] == nil {
334		i.extWidgets[extName] = map[string]extensionWidget{}
335	}
336	i.extWidgets[extName][id] = extensionWidget{
337		Position: position,
338		Title:    title,
339		Lines:    append([]string(nil), lines...),
340	}
341	i.mu.Unlock()
342	i.invalidate()
343}
344func (i *Interactive) ClearWidget(extName, id string) {
345	i.mu.Lock()
346	if items := i.extWidgets[extName]; items != nil {
347		delete(items, id)
348		if len(items) == 0 {
349			delete(i.extWidgets, extName)
350		}
351	}
352	i.mu.Unlock()
353	i.invalidate()
354}
355func (i *Interactive) ClearExtensionChrome(extName string) {
356	if strings.TrimSpace(extName) == "" {
357		return
358	}
359	marker := "[" + extName + "] "
360	i.mu.Lock()
361	changed := false
362	if _, ok := i.extStatuses[extName]; ok {
363		delete(i.extStatuses, extName)
364		changed = true
365	}
366	if _, ok := i.extWidgets[extName]; ok {
367		delete(i.extWidgets, extName)
368		changed = true
369	}
370	if len(i.extNotes) > 0 {
371		kept := i.extNotes[:0:0]
372		for _, line := range i.extNotes {
373			if strings.Contains(line, marker) {
374				changed = true
375				continue
376			}
377			kept = append(kept, line)
378		}
379		i.extNotes = kept
380	}
381	if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
382		i.extPanel.Close()
383		changed = true
384	}
385	i.mu.Unlock()
386	if changed {
387		i.invalidate()
388	}
389}
390func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
391	i.mu.Lock()
392	defer i.mu.Unlock()
393	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
394	i.confirmDialog.Blur()
395	if i.cfg.Extensions != nil {
396		cols, rows := i.cfg.Terminal.Size()
397		_ = cols
398		_ = rows
399	}
400	i.invalidate()
401}
402func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
403	i.mu.Lock()
404	defer i.mu.Unlock()
405	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
406		i.extPanel.Update(title, lines, footer)
407		i.invalidate()
408	}
409}
410func (i *Interactive) ClosePanel(extName, panelID string) {
411	i.mu.Lock()
412	defer i.mu.Unlock()
413	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
414		i.extPanel.Close()
415		i.invalidate()
416	}
417}

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