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}