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