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}