v0.48.1 deaab59

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

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