v0.46.0 ea0e112

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.cfg.detachMismatchedModelProfile()
109	i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
110	i.cumUsage = ag.Cost()
111	last := ag.LastTurnUsage()
112	i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
113	if len(i.view.Messages) > initialResumeTailLimit {
114		i.view.TailLimit = initialResumeTailLimit
115	} else {
116		i.view.TailLimit = 0
117	}
118	i.view.InvalidateRenderCache()
119	i.toolCalls = map[string]*tui.ToolCallView{}
120	i.toolOrder = nil
121	i.stream.Reset()
122	i.helpBlock = nil
123	i.sessionInfoBlocks = nil
124	i.extNotes = nil
125	i.parkedTurn = 0
126	i.parkedTotal = 0
127	i.mu.Unlock()
128	i.invalidate()
129}
130
131// ApplySessionAgentWithCWD commits a resumed agent together with its stored
132// workspace. Unlike a fresh /cd transition it preserves the session's compact
133// handoff and title while rebinding picker and terminal cwd state.
134func (i *Interactive) ApplySessionAgentWithCWD(ag *core.Agent, providerName, model, cwd string, compactHandoff json.RawMessage) {
135	i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, compactHandoff)
136	if cwd == "" {
137		return
138	}
139	home, _ := os.UserHomeDir()
140	profiles, _ := subagents.Discover(cwd, home)
141	i.mu.Lock()
142	i.cfg.CWD = cwd
143	i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles)
144	i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
145	i.cfg.StartupContextPaths = nil
146	i.view.StartupContextPaths = nil
147	if i.cfg.Terminal != nil {
148		if seq := tui.ReportCWD(cwd); seq != "" {
149			_, _ = i.cfg.Terminal.Write([]byte(seq))
150		}
151	}
152	i.mu.Unlock()
153	i.fileSuggest.Reset()
154	i.fileSuggest.SetCWD(cwd)
155	i.invalidate()
156}
157
158func (i *Interactive) SetSubagentSessionScope(_ string) {
159	i.invalidate()
160}
161func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
162	i.applyChangedCWD(ag, provider, model, cwd, nil)
163}
164func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
165	i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
166}
167func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
168	home, _ := os.UserHomeDir()
169	profiles, _ := subagents.Discover(cwd, home)
170	subagentsAddendum := subagents.SystemPromptAddendum(profiles)
171
172	i.agentMu.Lock()
173	defer i.agentMu.Unlock()
174	i.mu.Lock()
175	i.prepareReplacementAgentLocked(ag)
176	i.resetCompactContinuationLocked()
177	i.agent = ag
178	i.cfg.CWD = cwd
179	i.cfg.SubagentsSystemAddendum = subagentsAddendum
180	i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
181	i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
182	i.view.StartupContextPaths = nil
183	i.view.InvalidateRenderCache()
184	if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
185		i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
186	}
187	// Re-report the working directory to the terminal so "new tab here"
188	// tracks the /cd change (OSC 7, see issue #38).
189	if i.cfg.Terminal != nil {
190		if seq := tui.ReportCWD(cwd); seq != "" {
191			_, _ = i.cfg.Terminal.Write([]byte(seq))
192		}
193	}
194	i.cfg.Provider = provider
195	i.cfg.Model = model
196	i.cfg.detachMismatchedModelProfile()
197	titleCancel := i.titleCancel
198	i.titleCancel = nil
199	i.titleVersion++
200	i.sessionTitle = ""
201	i.titleRealPromptSeen = false
202	i.titleGenerationStarted = false
203	i.writeTerminalTitleLocked("")
204	i.toolCalls = map[string]*tui.ToolCallView{}
205	i.toolOrder = nil
206	i.stream.Reset()
207	i.helpBlock = nil
208	i.sessionInfoBlocks = nil
209	i.parkedTurn = 0
210	i.statusErr = ""
211	i.mu.Unlock()
212	if titleCancel != nil {
213		titleCancel()
214	}
215	i.fileSuggest.Reset()
216	i.fileSuggest.SetCWD(cwd)
217	i.invalidate()
218}
219func (i *Interactive) SubmitSlash(text string) {
220	i.mu.Lock()
221	i.doubleEscape.Reset()
222	i.mu.Unlock()
223	text = strings.TrimSpace(text)
224	if !strings.HasPrefix(text, "/") {
225		return
226	}
227	if slashCommandCancelsTurn(text) {
228		i.cancelAndWaitForIdle()
229	}
230	i.runSlash(i.runCtx, text)
231	i.invalidate()
232}
233func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
234	i.submitOrQueue(text, images, true)
235}
236
237// SubmitFollowUp submits a scheduler-owned prompt without interpreting shell
238// escapes or steering an active agent loop. A busy interactive waits for its
239// current turn to end, then starts this prompt as a distinct follow-up turn.
240func (i *Interactive) SubmitFollowUp(ctx context.Context, text string) error {
241	text = strings.TrimSpace(text)
242	if text == "" {
243		return fmt.Errorf("scheduled prompt is empty")
244	}
245	i.mu.Lock()
246	if i.agent == nil {
247		i.mu.Unlock()
248		return fmt.Errorf("no agent running; log in first")
249	}
250	if i.busy {
251		accepted := make(chan error, 1)
252		i.scheduled = append(i.scheduled, scheduledFollowUp{text: text, accepted: accepted})
253		i.mu.Unlock()
254		i.invalidate()
255		select {
256		case err := <-accepted:
257			return err
258		case <-ctx.Done():
259			return ctx.Err()
260		}
261	}
262	i.mu.Unlock()
263	// The active turn can finish before the busy check above, landing
264	// here instead of the scheduled path. runCtx is only set by Run, so
265	// fall back like the turn-completion handler does rather than
266	// panicking in context.WithCancel.
267	parent := i.runCtx
268	if parent == nil {
269		parent = context.Background()
270	}
271	i.startTurn(parent, text)
272	return nil
273}
274func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
275	i.submitOrQueueMessage(core.QueuedMessage{Text: text, Images: images}, userInput)
276}
277func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput bool) {
278	text, images := message.Text, message.Images
279	if cmd, ok := shellEscapeCommand(text); ok {
280		i.startShellEscape(i.runCtx, cmd)
281		return
282	}
283	i.mu.Lock()
284	if i.agent == nil {
285		i.statusErr = "not logged in. type /login first."
286		i.mu.Unlock()
287		i.invalidate()
288		return
289	}
290	i.mu.Unlock()
291	if userInput && i.cfg.EnsureMission != nil {
292		if err := i.cfg.EnsureMission(text); err != nil {
293			i.ReportError(fmt.Errorf("persist mission: %w", err))
294			return
295		}
296	}
297	// User input is an orchestration event, not merely a queue append. When
298	// the manager is dormant behind a sealed worker wave, the coordinator makes
299	// this the next manager turn and carries any completed worker summary with it.
300	if userInput && i.coordinatorAcceptsUserInput() {
301		actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text, Images: images})
302		i.executeCoordinatorActions(actions)
303		// The coordinator has accepted the input for its sealed worker wave.
304		// It may intentionally defer an action until pending workers finish;
305		// falling through would start a normal turn and discard that wave.
306		return
307	}
308	i.maybeStartSessionTitle(i.runCtx, text)
309	i.mu.Lock()
310	if i.busy {
311		// Keep the interactive mutex held through Agent.QueueMessage so turn teardown
312		// cannot inspect the agent queue between the busy check and enqueue.
313		// Compaction uses the host queue because it has no active agent loop
314		// to drain Agent.QueueMessage entries.
315		var handoff json.RawMessage
316		var persistHandoff bool
317		if i.agent != nil && !i.compacting {
318			handoff, persistHandoff = i.resetCompactContinuationLocked()
319			i.agent.QueuePrompt(message)
320		} else {
321			handoff, persistHandoff = i.resetCompactContinuationLocked()
322			i.queued = append(i.queued, message)
323		}
324		i.mu.Unlock()
325		if persistHandoff {
326			i.persistCompactHandoff(handoff)
327		}
328		i.invalidate()
329		return
330	}
331	i.mu.Unlock()
332	i.startQueuedTurn(i.runCtx, message)
333}
334
335func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage {
336	var pending []core.QueuedMessage
337	if i.agent != nil {
338		pending = i.agent.DrainQueuedMessages()
339	}
340	pending = append(pending, i.queued...)
341	i.queued = nil
342	return pending
343}
344
345func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) {
346	pending := i.takeQueuedMessagesLocked()
347	if !preserveHostEvents {
348		return
349	}
350	for _, message := range pending {
351		if !message.HostEvent {
352			continue
353		}
354		if i.agent != nil {
355			i.agent.QueuePrompt(message)
356		} else {
357			i.queued = append(i.queued, message)
358		}
359	}
360}
361
362func (i *Interactive) ChangelogVersion() string {
363	if i.changelogDialog != nil {
364		return i.changelogDialog.version
365	}
366	return ""
367}
368func (i *Interactive) CancelTurn() {
369	i.mu.Lock()
370	cancel := i.cancelTurn
371	i.mu.Unlock()
372	if cancel != nil {
373		i.cancelCoordinator()
374
375		i.mu.Lock()
376		handoff, persistHandoff := i.resetCompactContinuationLocked()
377		i.mu.Unlock()
378		cancel()
379		i.confirmDialog.CancelAll("turn cancelled")
380		if persistHandoff {
381			i.persistCompactHandoff(handoff)
382		}
383	}
384}
385func (i *Interactive) Insert(text string) {
386	i.ed.Insert(text)
387	i.invalidate()
388}
389func (i *Interactive) Display(extName, text string) {
390	i.appendExtensionNote(extName, text, "info")
391	i.invalidate()
392}
393func (i *Interactive) ReportError(err error) {
394	if err == nil {
395		return
396	}
397	i.mu.Lock()
398	i.statusOK = ""
399	i.statusErr = err.Error()
400	i.mu.Unlock()
401	i.invalidate()
402}
403func (i *Interactive) SetStatus(extName, key, level, text string) {
404	if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
405		return
406	}
407	i.mu.Lock()
408	if i.extStatuses == nil {
409		i.extStatuses = map[string]map[string]extensionStatus{}
410	}
411	if strings.TrimSpace(text) == "" {
412		if items := i.extStatuses[extName]; items != nil {
413			delete(items, key)
414			if len(items) == 0 {
415				delete(i.extStatuses, extName)
416			}
417		}
418	} else {
419		if i.extStatuses[extName] == nil {
420			i.extStatuses[extName] = map[string]extensionStatus{}
421		}
422		i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
423	}
424	i.mu.Unlock()
425	i.invalidate()
426}
427func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
428	if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
429		return
430	}
431	position = extproto.NormalizeWidgetPosition(position)
432	i.mu.Lock()
433	if i.extWidgets == nil {
434		i.extWidgets = map[string]map[string]extensionWidget{}
435	}
436	if i.extWidgets[extName] == nil {
437		i.extWidgets[extName] = map[string]extensionWidget{}
438	}
439	i.extWidgets[extName][id] = extensionWidget{
440		Position: position,
441		Title:    title,
442		Lines:    append([]string(nil), lines...),
443	}
444	i.mu.Unlock()
445	i.invalidate()
446}
447func (i *Interactive) ClearWidget(extName, id string) {
448	i.mu.Lock()
449	if items := i.extWidgets[extName]; items != nil {
450		delete(items, id)
451		if len(items) == 0 {
452			delete(i.extWidgets, extName)
453		}
454	}
455	i.mu.Unlock()
456	i.invalidate()
457}
458func (i *Interactive) ClearExtensionChrome(extName string) {
459	if strings.TrimSpace(extName) == "" {
460		return
461	}
462	marker := "[" + extName + "] "
463	i.mu.Lock()
464	changed := false
465	if _, ok := i.extStatuses[extName]; ok {
466		delete(i.extStatuses, extName)
467		changed = true
468	}
469	if _, ok := i.extWidgets[extName]; ok {
470		delete(i.extWidgets, extName)
471		changed = true
472	}
473	if len(i.extNotes) > 0 {
474		kept := i.extNotes[:0:0]
475		for _, line := range i.extNotes {
476			if strings.Contains(line, marker) {
477				changed = true
478				continue
479			}
480			kept = append(kept, line)
481		}
482		i.extNotes = kept
483	}
484	if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
485		i.extPanel.Close()
486		changed = true
487	}
488	i.mu.Unlock()
489	if changed {
490		i.invalidate()
491	}
492}
493func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
494	i.mu.Lock()
495	defer i.mu.Unlock()
496	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
497	i.confirmDialog.Blur()
498	if i.cfg.Extensions != nil {
499		cols, rows := i.cfg.Terminal.Size()
500		_ = cols
501		_ = rows
502	}
503	i.invalidate()
504}
505func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
506	i.mu.Lock()
507	defer i.mu.Unlock()
508	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
509		i.extPanel.Update(title, lines, footer)
510		i.invalidate()
511	}
512}
513func (i *Interactive) ClosePanel(extName, panelID string) {
514	i.mu.Lock()
515	defer i.mu.Unlock()
516	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
517		i.extPanel.Close()
518		i.invalidate()
519	}
520}

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