1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "sort"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
16 "github.com/bnema/zut/packages/agent/extensions"
17 "github.com/bnema/zut/packages/agent/extproto"
18 "github.com/bnema/zut/packages/agent/modes/telegram"
19 "github.com/bnema/zut/packages/agent/skills"
20 "github.com/bnema/zut/packages/agent/subagents"
21 "github.com/bnema/zut/packages/agent/tools"
22 "github.com/bnema/zut/packages/core"
23 "github.com/bnema/zut/packages/provider"
24 "github.com/bnema/zut/packages/provider/auth"
25 "github.com/bnema/zut/packages/tui"
26)
27
28// InteractiveConfig configures the interactive loop.
29type InteractiveConfig struct {
30 Terminal tui.Terminal
31 Theme tui.Theme
32 Model string
33 Provider string
34 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
35 BaseURL string
36 Reasoning string
37 SystemPrompt string
38 Tools core.Registry
39 MaxSteps int
40 CWD string
41
42 // Startup resource fields are loaded inputs available to list before the
43 // transcript. They are never sent to the provider or persisted by the view.
44 StartupAgentName string
45 StartupContextPaths []string
46 StartupExtensionNames []string
47 StartupSkillNames []string
48
49 // StartupExtensionErrors are non-fatal extension load failures collected
50 // before the TUI starts. They are display-only and never enter the transcript.
51 StartupExtensionErrors []string
52
53 // ShowInstructionsAtStartup controls whether startup resources are visible.
54 // nil means the default, off.
55 ShowInstructionsAtStartup *bool
56
57 // InlineImagesEnabled overrides terminal image rendering. nil means
58 // auto-detect and render when supported; false disables; true uses
59 // the detected protocol when available.
60 InlineImagesEnabled *bool
61
62 // TerminalAlertsEnabled controls interactive alerts from the main
63 // agent and extensions. nil means enabled; false disables them.
64 TerminalAlertsEnabled *bool
65
66 // TerminalTitleEnabled controls the hidden title request and terminal
67 // title updates. nil means enabled; false disables both.
68 TerminalTitleEnabled *bool
69
70 // AutoSubagentsEnabled mirrors the persisted config flag at startup so
71 // the /settings dialog can render the current state without
72 // re-reading config.json on every open.
73 AutoSubagentsEnabled *bool
74
75 // PonytailEnabled mirrors the persisted default-on coding-guidance
76 // setting at startup. nil/missing means enabled.
77 PonytailEnabled *bool
78
79 // AutoSubagentsToolAllowed is nil for normal sessions and false when the
80 // launch-time --no-tools/--tools policy excludes subagent spawning.
81 AutoSubagentsToolAllowed *bool
82
83 // AutoSubagentsStatusToolAllowed is nil for normal sessions and false when
84 // the launch-time --no-tools/--tools policy excludes status queries.
85 AutoSubagentsStatusToolAllowed *bool
86
87 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
88 // nil/missing means disabled. Unsupported providers reject attempts
89 // to enable it and reject requests when it remains enabled.
90 FastMode *bool
91
92 // LSPEnabled controls the main session's built-in lsp tool. nil means
93 // enabled by default.
94 LSPEnabled *bool
95
96 // SubagentLSPEnabled controls whether newly spawned subagent children
97 // receive the lsp tool. nil means enabled by default.
98 SubagentLSPEnabled *bool
99
100 // AutoCompactThreshold is the context-window percentage that triggers
101 // automatic compaction. nil means 85; zero disables percentage-based
102 // triggers while preserving payload-too-large recovery.
103 AutoCompactThreshold *int
104
105 // JailByDefault mirrors the persisted jail_by_default preference.
106 // The current sandbox may differ after a session-scoped /jail or /unjail.
107 JailByDefault *bool
108
109 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
110 // flag at startup. When true the @-mention picker fuzzy-searches the
111 // whole project tree instead of browsing one directory at a time.
112 RecursiveFileSuggest *bool
113
114 // RespectGitignore mirrors the persisted respect_gitignore flag at
115 // startup. nil means the default (on); when false the @-mention
116 // picker shows files matched by the project's root .gitignore.
117 RespectGitignore *bool
118
119 // ThemeName mirrors the persisted config theme value. Empty means auto.
120 ThemeName string
121
122 // FlatTools renders tool calls without the bordered panel (a quiet
123 // header line plus indented, frameless output). Mirrors the
124 // resolved tool_render config / ZUT_FLAT_TOOLS env at startup.
125 FlatTools bool
126
127 // CompactUser renders sent user messages as a single quiet gutter
128 // line instead of a padded, tinted bubble. Mirrors the resolved
129 // compact_input config / ZUT_COMPACT_INPUT env at startup.
130 CompactUser bool
131
132 // CompactMode mirrors the persisted compact_mode flag at startup.
133 // When true, it enables both flat tool calls and compact user turns.
134 CompactMode *bool
135
136 // TUIInputStyle controls the main input rendering: plain or lines.
137 TUIInputStyle string
138
139 // TUIStatusPosition controls whether the status block renders above
140 // or below the main input.
141 TUIStatusPosition string
142
143 // TUIWorkingPosition controls whether the busy/working spinner renders
144 // above or below the main input.
145 TUIWorkingPosition string
146
147 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
148 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
149 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
150 QuickModelShortcuts []QuickModelShortcut
151
152 // ExtensionThemes returns themes bundled with loaded extensions.
153 ExtensionThemes func() []tui.ThemeOption
154
155 // AutoSubagentsSystemAddendum is the system-prompt block that gets
156 // appended/stripped when the user toggles auto-subagents at runtime.
157 // Plumbed in from the cli so this package doesn't have to import
158 // agent (cycle).
159 AutoSubagentsSystemAddendum string
160
161 // SubagentsSystemAddendum is the metadata-only [subagents_list]
162 // block that is added or removed together with auto-subagents.
163 SubagentsSystemAddendum string
164 SettingsStore SettingsStore
165
166 // Agent is optional. If nil, zut opens without credentials; the
167 // user must /login before they can prompt.
168 Agent *core.Agent
169
170 InitialInput string
171
172 // StartupPre is auto-submitted once when the interactive session
173 // opens, before InitialInput is applied. Uses the same path as a
174 // typed Enter submit ("!" → shell escape, otherwise a model turn).
175 StartupPre string
176
177 // OnStartupPreDone runs after StartupPre finishes (shell escape or
178 // model turn), before deferred InitialInput is applied. Hosts use
179 // it to rediscover skills / reload extensions installed by pre.
180 OnStartupPreDone func()
181
182 // RefreshTools re-resolves the live agent registry after a setting
183 // changes the main session's tool availability. Optional for embedders.
184 RefreshTools func() error
185
186 // RefreshPrompt re-resolves the complete live prompt and tool registry
187 // after a prompt-affecting setting changes. Optional for embedders.
188 RefreshPrompt func() error
189
190 // AutoSubmitInitial, when true, auto-submits InitialInput after
191 // StartupPre completes (or immediately when StartupPre is empty).
192 // When false, InitialInput only pre-fills the editor.
193 AutoSubmitInitial bool
194
195 // Auth is required. When the user runs /login, Interactive talks to
196 // AuthManager to open a browser and wait for the callback.
197 AuthManager *auth.Manager
198
199 // LlamaCPPConfig resolves the router URL and optional API key from env or
200 // the credential saved by /login.
201 LlamaCPPConfig func() (baseURL, apiKey string, err error)
202
203 // RefreshLlamaCPPModels synchronizes currently loaded router models into
204 // the catalog before /model opens.
205 RefreshLlamaCPPModels func(context.Context) error
206
207 // BuildAgent is called after a successful login to (re)construct the
208 // agent with the fresh credential. It returns the new agent and
209 // the concrete provider/model in use.
210 BuildAgent func() (*core.Agent, string, string, error)
211
212 // SetKimiCLIFallbackDisabled controls whether zut may fall back to
213 // the official Kimi Code CLI token when zut has no stored Kimi token.
214 SetKimiCLIFallbackDisabled func(disabled bool) error
215
216 // BuildAgentFor rebuilds the agent with an explicit provider/model
217 // override (used by the /model picker when switching providers).
218 // If providerOverride is empty, the current provider is kept.
219 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
220
221 // BuildAgentForRescue rebuilds the agent for the rescue picker that
222 // opens after a recoverable provider failure. Unlike BuildAgentFor,
223 // this builder drops launch-time --api-key and --base-url overrides
224 // because those are usually the reason rescue triggered. Re-resolves
225 // credentials from env vars / auth.json / provider defaults so the
226 // retry has a real chance of succeeding. Falls back to BuildAgentFor
227 // when nil so embedders that don't wire it keep working.
228 BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
229
230 // LoggedInProviders returns the list of provider names that
231 // currently have credentials. Used by /model to filter the
232 // picker to only show reachable models.
233 LoggedInProviders func() []string
234
235 // ZutHome is zut's global state directory, used by authentication,
236 // themes, extensions, and other shared configuration.
237 ZutHome string
238
239 // SessionsRoot is the root passed to core session operations. It differs
240 // from ZutHome for Zutfile agents, whose sessions are isolated by agent
241 // name. Empty falls back to ZutHome for embedders and tests.
242 SessionsRoot string
243
244 // Version is the binary's current version (from main.version).
245 // Used only for display; the update check itself is done outside
246 // this package to avoid an import cycle.
247 Version string
248
249 // UpdateInfoChan is an optional channel that delivers the result
250 // of the github-release update check. Interactive reads at most
251 // one value, drops it if the check reported nothing, and otherwise
252 // surfaces a yellow "update available" banner at the top of the
253 // chat. Nil channel = no banner, no startup cost.
254 UpdateInfoChan <-chan UpdateInfo
255
256 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
257 Sandbox *tools.Sandbox
258
259 // LoadSession swaps the current session for the one at path. The
260 // callback returns the new agent message slice so the TUI can invalidate.
261 LoadSession func(path string) error
262
263 // ChangeCWD switches the running zut session's working directory
264 // to path. The host closes the current session, rebuilds the
265 // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
266 // opens a fresh session there. Returns an error if path doesn't
267 // exist, isn't a directory, or the host can't rebuild the agent.
268 //
269 // Optional: not wired by every embedder. When nil the hidden /cd
270 // command surfaces a clear error rather than no-oping.
271 ChangeCWD func(path string) error
272
273 // CurrentSessionPath returns the path of the live session file
274 // on disk (the one every AppendMessage writes to). Used by
275 // /session export so the exporter ships the exact bytes on
276 // disk. Returns an empty string when --no-session is set or
277 // no session is open.
278 CurrentSessionPath func() string
279
280 // FlushSession writes any in-memory agent messages to the
281 // session file that haven't been persisted yet. Called by
282 // /session export right before reading the file so the
283 // exported bytes reflect the full current conversation, not
284 // just the rows the agent happened to write synchronously.
285 // The default WriteNewTranscript-at-exit strategy means most
286 // of a running session lives only in memory until the tui
287 // closes; without a flush hook, /session export writes a
288 // file that only has the meta row.
289 FlushSession func()
290
291 // SessionTransition serializes a state-changing session/model
292 // operation with the host's session load. It is optional for embedders;
293 // the CLI uses it to prevent a slow provider rebuild from being replaced
294 // by a concurrent resume (or vice versa).
295 SessionTransition func(func())
296
297 // PersistModel is called whenever the user switches model or provider.
298 // It should update config.json and (if there's an active session)
299 // write a new meta row so resume picks up the same model.
300 PersistModel func(providerName, model string)
301
302 // InitialSessionTitle is the persisted title of the session loaded before
303 // the TUI starts. It is shown in the terminal without another model call.
304 InitialSessionTitle string
305 // InitialSessionTitlePending is true for a newly forked branch that only
306 // contains its copied prefix. The copied user messages must not prevent a
307 // new title after the branch's first real prompt.
308 InitialSessionTitlePending bool
309
310 // CurrentSessionTitle returns the title of the session currently owned by
311 // the host. It is used after /sessions and import switches.
312 CurrentSessionTitle func() string
313 // CurrentSessionTitlePending reports whether the loaded session is a new
314 // branch that has not received a post-fork prompt yet.
315 CurrentSessionTitlePending func() bool
316
317 // PersistTitle stores an automatically generated title without adding a
318 // user or assistant message to the transcript. It returns persistence
319 // errors without affecting the main turn.
320 PersistTitle func(title string) error
321 // OnSessionTitleChanged updates host-side in-memory title bookkeeping
322 // after a manual rename has already been written to disk.
323 OnSessionTitleChanged func(title string)
324
325 OnAssistant func(m provider.Message)
326 OnToolResult func(id string, r core.ToolResult)
327
328 // Extensions, if non-nil, lets users invoke extension-registered
329 // slash commands. Commands declared by extensions are looked up
330 // AFTER the built-in catalog so a built-in name always wins.
331 Extensions *extensions.Manager
332
333 // Supervisor, if non-nil, enables the /subagents slash command and the
334 // dashboard dialog. The cli constructs the Supervisor once per
335 // interactive run and tears it down on exit. nil disables the
336 // feature entirely (used by embedders / tests that don't want
337 // subprocesses).
338 Supervisor *subagents.Supervisor
339
340 // ResolveSubagent validates a named markdown profile for direct
341 // /subagents commands. Auto-subagents uses the equivalent callback on its
342 // tool; keeping this optional preserves lightweight embedders.
343 ResolveSubagent func(name string) (*subagents.Profile, error)
344
345 // SkillSnapshot, if non-nil, returns the current list of
346 // discovered SKILL.md files. Re-invoked each time /skills opens
347 // so the picker reflects edits made during the session.
348 SkillSnapshot func() []*skills.Skill
349
350 // ChangelogChan, if non-nil, delivers release-notes for the
351 // current binary version once at startup. Interactive opens a
352 // dismissible overlay when the channel produces a non-empty
353 // body. Receiver fires at most once.
354 ChangelogChan <-chan ChangelogPayload
355
356 // OnChangelogDismiss, if non-nil, is called once the user
357 // closes the changelog overlay. The cli wires this to a
358 // MarkChangelogShown call so the same version doesn't show
359 // again on the next launch.
360 OnChangelogDismiss func()
361
362 // NoYolo is true when --no-yolo was passed. Interactive opens
363 // a confirmation dialog before every tool call and blocks the
364 // tool until the user picks yes / always-this-tool /
365 // always-all / no. When false (default), tools run freely.
366 NoYolo bool
367
368 // ConfirmGate is the session-scoped gate wrapping this
369 // interactive's Confirmer. When non-nil, /yolo can call
370 // AllowAll() on it to disable confirmation for the rest of the
371 // session. When nil (yolo mode), /yolo reports that there's
372 // nothing to disable.
373 ConfirmGate *core.ConfirmGate
374}
375
376// ChangelogPayload mirrors agent.ChangelogInfo without the import
377// cycle. The cli builds one from the http response, the tui opens
378// the overlay when one arrives.
379type ChangelogPayload struct {
380 Version string
381 Body string
382 URL string
383}
384
385type modelRefreshResult struct{ err error }
386
387// Interactive is the TUI chat loop.
388type chatCacheKey struct {
389 cols int
390 agentRev uint64
391 statusOK string
392 statusErr string
393 help string
394 extNotes string
395 extStatuses string
396 extWidgets string
397 reloadErrors string
398 updateAvailable bool
399 updateCurrent string
400 updateLatest string
401 updateURL string
402 welcomeShowVer bool
403 expandAll bool
404 tailLimit int
405}
406
407// QuickModelShortcut is one configured quick model switch slot.
408type QuickModelShortcut struct {
409 Provider string
410 Model string
411}
412
413type extensionStatus struct {
414 Level string
415 Text string
416}
417
418type extensionWidget struct {
419 Position string
420 Title string
421 Lines []string
422}
423
424// SettingsStore persists user-toggleable settings surfaced by /settings.
425type SettingsStore interface {
426 SetQuickModelShortcut(slot int, providerName, model string) error
427 SetInlineImages(enabled bool) error
428 SetAutoSubagents(enabled bool) error
429 SetJailByDefault(enabled bool) error
430 SetRecursiveFileSuggest(enabled bool) error
431 SetRespectGitignore(enabled bool) error
432 SetCompactMode(enabled bool) error
433 SetTUIInputStyle(style string) error
434 SetTUIStatusPosition(position string) error
435 SetTUIWorkingPosition(position string) error
436 SetReasoning(level string) error
437 SetTheme(name string) error
438}
439
440type terminalAlertsSettingsStore interface {
441 SetTerminalAlertsEnabled(enabled bool) error
442}
443
444type terminalTitleSettingsStore interface {
445 SetTerminalTitleEnabled(enabled bool) error
446}
447
448type lspSettingsStore interface {
449 SetLSPEnabled(enabled bool) error
450 SetSubagentLSPEnabled(enabled bool) error
451}
452
453type showInstructionsSettingsStore interface {
454 SetShowInstructionsAtStartup(enabled bool) error
455}
456
457type autoCompactThresholdSettingsStore interface {
458 SetAutoCompactThreshold(percent int) error
459}
460
461type fastModeSettingsStore interface {
462 SetFastMode(enabled bool) error
463}
464
465type ponytailSettingsStore interface {
466 SetPonytailEnabled(enabled bool) error
467}
468
469type Interactive struct {
470 cfg InteractiveConfig
471 view *tui.View
472 ed *tui.Editor
473 rend *tui.Renderer
474
475 mu sync.Mutex
476 agent *core.Agent
477 // agentMu serializes live registry replacement with side-dialog
478 // snapshots, whose copied-agent constructor reads the registry field.
479 agentMu sync.Mutex
480 // managedAutoSubagentsAddenda records exact prompt blocks appended by
481 // auto-subagents. Disable only removes these owned occurrences, leaving
482 // identical text that came from the user's base prompt untouched.
483 managedAutoSubagentsAddenda []string
484 streaming strings.Builder // what's currently painted on screen
485 streamOn bool
486 pendingAlert *extproto.AlertRequest
487
488 // streamPending is the runes buffered after each EvTextDelta that
489 // haven't yet been promoted into `streaming` for rendering. It
490 // exists because some provider paths (notably Anthropic via the
491 // oauth/subscription channel) coalesce the model's output into a
492 // few fat chunks instead of drip-streaming. Painting those fat
493 // chunks verbatim looks like the summary "just appears". The
494 // paintPace goroutine drains a handful of runes per tick from
495 // this buffer into `streaming`, giving every path the same
496 // typewriter feel regardless of upstream chunk size.
497 streamPending []rune
498 // streamFlushPending is set when EvAssistantMessage fires while
499 // streamPending still has unrendered runes. The ticker flushes
500 // them, then closes out the stream (clearing flags, resetting
501 // buffers) so the final paint matches the on-disk message.
502 streamFlushPending bool
503 toolCalls map[string]*tui.ToolCallView
504 toolOrder []string
505 // toolGate records, per tool-call id, how many runes of paced
506 // assistant text must have drained into `streaming` before that
507 // tool block may appear. It exists to make stream ordering
508 // deterministic: a tool call can arrive from the provider while
509 // the prose that logically precedes it is still being typed out
510 // by the pacer. Without gating, the tool block would render
511 // immediately while the intro paragraph keeps filling in below
512 // it. We snapshot the total expected stream length (already
513 // streamed + still pending) at the moment the tool starts, and
514 // hold the block back until the pacer reaches it.
515 toolGate map[string]int
516 statusErr string
517 statusOK string
518 reloadStatusSeq uint64
519 extStatuses map[string]map[string]extensionStatus
520 extWidgets map[string]map[string]extensionWidget
521 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
522 liveBlock []string // live streaming/tool progress rendered outside scrollback
523 helpBlock []string // rendered above the chat when /help was typed
524 cumUsage provider.Usage
525 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
526 busy bool
527 activity agentActivity
528 pendingIdleWork []func()
529 dirty chan struct{}
530 modelRefresh chan modelRefreshResult
531 modelRefreshing bool
532 startupPreDone chan startupPreResult
533 cancelTurn context.CancelFunc
534 scrollOffset int // rows from the bottom; 0 = pinned to latest
535 prevScrollOffset int // last value redraw snapped against; tracks intent
536
537 // prevChatLen and prevChatCols track the chat buffer's size at the
538 // last redraw so that when content grows below the user's viewport
539 // while they're scrolled up reading history, we can bump
540 // scrollOffset by exactly the growth and keep the visible content
541 // pinned. Without this, every streamed line shifts the visible
542 // window down through the buffer (because scrollOffset is measured
543 // from the bottom) and the user's reading position drifts upward
544 // and off the top.
545 prevChatLen int
546 prevChatCols int
547 prevChatRows int
548 prevOverlayOpen bool
549 cursorDimmed bool
550
551 // chatCache stores the built transcript/status-note rows for idle
552 // frames. Editor typing changes only the bottom input region, so
553 // reusing this cache avoids copying/walking/reassembling a long
554 // session on every keypress.
555 chatCache []string
556 chatCacheKey chatCacheKey
557 chatCacheValid bool
558
559 // Messages typed while a turn is in flight. Each is delivered as
560 // its own follow-up turn once the current one finishes. Rendered
561 // above the status bar as "sliding in: ..." chips.
562 queued []string
563
564 // runCtx is the top-level context passed to Run(). Follow-up turns
565 // drained from `queued` are started against this context so they
566 // survive past the ctx of the key event that enqueued them.
567 runCtx context.Context
568
569 // pendingPostCompactNote is a status_ok message to surface after
570 // a successful auto-compact pass triggered by a 413 or by the
571 // pre-turn fraction guard. Cleared by runCompact once shown.
572 pendingPostCompactNote string
573
574 // A pre-turn compaction must preserve the complete pending request,
575 // including images. Overflow recovery instead continues the user message
576 // already appended to the agent transcript, avoiding a duplicate message.
577 pendingCompactPrompt string
578 pendingCompactImages []provider.ImageBlock
579 hasPendingCompactPrompt bool
580 continueAfterCompact bool
581
582 // compacting is true for both manual and automatic compaction. Prompts
583 // submitted while it is set stay in the host queue because Compact does
584 // not run the agent loop that drains the agent-owned queue.
585 compacting bool
586
587 // autoCompacting is true while a model-triggered compaction is in
588 // flight. Surfaced in the status bar so the user can tell a
589 // condense pass from a regular assistant turn.
590 autoCompacting bool
591
592 // updateInfo is the result of the async update check. Zero value
593 // while the check hasn't completed or nothing is available.
594 updateInfo UpdateInfo
595
596 dialog *loginDialog
597 modelDialog *modelDialog
598 llamaDialog *llamaDialog
599 rescueDialog *rescueDialog
600 sessionDialog *sessionDialog
601 subagentsDialog *subagentsDialog
602 jumpDialog *jumpDialog
603 btwDialog *btwDialog
604 skillsDialog *skillsDialog
605 changelogDialog *changelogDialog
606 confirmDialog *confirmDialog
607 logoutDialog *logoutDialog
608 telegramDialog *telegramDialog
609 settingsDialog *settingsDialog
610 quickModelAssign int
611 telegramBridge *telegram.Bridge
612 sessionOpsDialog *sessionOpsDialog
613 sessionTreeDialog *sessionTreeDialog
614 extPanel *extPanelDialog
615 llamaConfigured bool
616
617 // subagentWatch tracks auto-subagents sub-agents the main agent spawned
618 // via subagent_spawn. Each entry holds the agent + the task text;
619 // a per-entry goroutine waits on the agent's terminal state. When
620 // every tracked entry has finished, the watcher flushes a single
621 // summary turn into the main chat (queued if the main agent is
622 // busy, run immediately if idle).
623 subagentWatchMu sync.Mutex
624 subagentWatch []*subagentWatchEntry
625
626 // subagentsWaitWatcherDone is an optional test hook invoked when a
627 // /subagents wait watcher exits.
628 subagentsWaitWatcherDone func()
629
630 // pendingFork is true when the user ran /session fork: the next
631 // jump-picker selection should branch off that message instead
632 // of scrolling. Flag resets after the action fires or the dialog
633 // is dismissed, so repeated /jump calls don't turn into forks.
634 pendingFork bool
635 suggest *slashSuggester
636 fileSuggest *fileSuggester
637 spin *spinner
638
639 // parkedTurn is the 1-based turn number the viewport is currently
640 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
641 // Rendered as a muted footer at the bottom of the chat so users
642 // don't forget they're looking at history.
643 parkedTurn int
644 parkedTotal int
645
646 // inputHistoryIndex is -1 when not browsing history. When the
647 // editor is empty, Up/Down can walk previous user prompts without
648 // stealing normal vertical cursor movement in non-empty input.
649 inputHistoryIndex int
650
651 // lastCtrlC is when the user last pressed ctrl+c. The first press
652 // clears the editor / cancels a turn / shows a hint; a second press
653 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
654 lastCtrlC time.Time
655
656 // doubleEscape tracks the optional bare-Escape session-tree gesture.
657 // clock is injectable so the gesture can be tested without sleeping.
658 doubleEscape doubleEscapeTracker
659 clock func() time.Time
660
661 // welcomeStart is when the interactive run began. The welcome
662 // banner shows the binary version for welcomeVersionDuration
663 // after this point and reverts to plain text after.
664 welcomeStart time.Time
665
666 // interactiveStarted gates session-title generation so tests and
667 // embedders that call Submit before Run do not issue a hidden request.
668 interactiveStarted bool
669 // titleRealPromptSeen prevents a fresh session from generating more than
670 // one title. It is reset only when a fork or a new cwd starts a new branch.
671 titleRealPromptSeen bool
672 titleGenerationStarted bool
673 firstRealPrompt string
674 titleVersion uint64
675 titleCancel context.CancelFunc
676 sessionTitle string
677
678 // extNotes are one-shot styled lines pushed by extensions via
679 // Notify / Display. They live above the editor (just below the
680 // transcript) until cleared by /clear or another reset.
681 extNotes []string
682
683 // reloadErrors are host-only extension reload failures. They stay in
684 // the scrolling chat until /clear without entering the agent transcript.
685 reloadErrors []string
686
687 // shellRunning is true while a !command is executing. It shares
688 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
689 // escape can start while one is in flight.
690 shellRunning bool
691 // shellLive is the accumulating stdout/stderr of the in-flight
692 // shell escape, updated via BashTool progress for live rendering.
693 shellLive string
694
695 // awaitingStartupPre is true while the zutfile entry.pre auto-submit
696 // is in flight. When it clears, deferredInitialInput is applied.
697 awaitingStartupPre bool
698 deferredInitialInput string
699 autoSubmitDeferred bool
700
701 // sessionLoading is true while a /sessions selection is being read
702 // on a background goroutine. Keeping this off the input goroutine
703 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
704 sessionLoading bool
705
706 // sessionLoads carries per-entry results for the /sessions picker. The
707 // picker owns the load lifecycle; the interactive loop applies results so
708 // rendering never races with background file reads.
709 sessionLoads <-chan sessionLoadEvent
710
711 // pendingRescuePrompt / pendingRescueImages stash the prompt and
712 // images that should be re-run after the user picks a model in
713 // the rescue dialog. Cleared once applyRescueSelection consumes
714 // them (or when the dialog is dismissed via esc).
715 pendingRescuePrompt string
716 pendingRescueImages []provider.ImageBlock
717
718 clipboardImages []clipboardImageAttachment
719}
720
721// welcomeVersionDuration is how long the welcome banner shows the
722// version suffix before reverting to the plain headline. 1.5s is
723// enough to read at a glance and keeps the splash short.
724const welcomeVersionDuration = 1500 * time.Millisecond
725
726// initialResumeTailLimit caps how many messages from a freshly-resumed
727// transcript we render on the first paint. The full transcript is
728// still in memory; older messages are rendered (and their cached
729// lines kept for the lifetime of the View) as soon as the user
730// scrolls past the rendered tail. Picked to comfortably fill the
731// largest realistic terminal viewport while keeping first paint
732// snappy on multi-thousand-message sessions where markdown / syntax
733// highlighting dominates the redraw cost.
734const initialResumeTailLimit = 80
735
736// resumeTailExpandStep is how many additional messages the tail
737// limit grows by each time the user scrolls past the currently
738// rendered top. Pre-rendering this many messages on a single tick
739// keeps scroll-up smooth without falling back to a one-by-one
740// reveal that would feel jerky.
741const resumeTailExpandStep = 80
742
743type startupPreResult struct {
744 deferred string
745 autoSubmit bool
746}
747
748// NewInteractive constructs an Interactive from cfg.
749func NewInteractive(cfg InteractiveConfig) *Interactive {
750 renderer := tui.NewRenderer(cfg.Terminal)
751 renderer.SetTheme(cfg.Theme)
752 startupAgentName := ""
753 startupContextPaths := []string(nil)
754 startupExtensionNames := []string(nil)
755 startupSkillNames := []string(nil)
756 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
757 startupAgentName = cfg.StartupAgentName
758 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
759 startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
760 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
761 }
762 i := &Interactive{
763 cfg: cfg,
764 view: &tui.View{
765 Theme: cfg.Theme,
766 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
767 FlatTools: cfg.FlatTools,
768 CompactUser: cfg.CompactUser,
769 CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
770 StartupAgentName: startupAgentName,
771 StartupContextPaths: startupContextPaths,
772 StartupExtensionNames: startupExtensionNames,
773 StartupSkillNames: startupSkillNames,
774 },
775 // Prompt is the standard half-block accent bar used by chat
776 // speaker labels too, so the input gutter matches the rest
777 // of the UI.
778 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
779 rend: renderer,
780 toolCalls: map[string]*tui.ToolCallView{},
781 toolGate: map[string]int{},
782 dirty: make(chan struct{}, 8),
783 modelRefresh: make(chan modelRefreshResult, 1),
784 startupPreDone: make(chan startupPreResult, 1),
785 dialog: newLoginDialog(),
786 modelDialog: newModelDialog(),
787 llamaDialog: newLlamaDialog(),
788 rescueDialog: newRescueDialog(),
789 sessionDialog: newSessionDialog(),
790 subagentsDialog: newSubagentsDialog(),
791 jumpDialog: newJumpDialog(),
792 btwDialog: newBtwDialog(),
793 skillsDialog: newSkillsDialog(),
794 changelogDialog: newChangelogDialog(),
795 confirmDialog: newConfirmDialog(),
796 logoutDialog: newLogoutDialog(),
797 telegramDialog: newTelegramDialog(),
798 settingsDialog: newSettingsDialog(),
799 sessionOpsDialog: newSessionOpsDialog(),
800 sessionTreeDialog: newSessionTreeDialog(),
801 extPanel: newExtPanelDialog(),
802 extStatuses: map[string]map[string]extensionStatus{},
803 extWidgets: map[string]map[string]extensionWidget{},
804 suggest: newSlashSuggester(),
805 fileSuggest: newFileSuggester(),
806 spin: newSpinner(cfg.Theme),
807 inputHistoryIndex: -1,
808 clock: time.Now,
809 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
810 }
811 i.btwDialog.setCloseHook(func() {
812 i.confirmDialog.CancelChildConfirmations("side chat closed")
813 })
814 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
815 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
816 if cfg.LlamaCPPConfig != nil {
817 baseURL, _, err := cfg.LlamaCPPConfig()
818 i.llamaConfigured = err == nil && baseURL != ""
819 }
820 if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
821 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
822 }
823 if cfg.Agent != nil {
824 i.agent = cfg.Agent
825 i.view.Messages = cfg.Agent.Messages()
826 i.cumUsage = cfg.Agent.Cost()
827 // Rehydrate the "context used" gauge from the last persisted
828 // turn. Without this the status bar reads 0.0% after a resume
829 // until the next turn lands a usage event.
830 if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
831 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
832 }
833 // Cap the first paint at the tail of the transcript so
834 // resuming a multi-thousand-message session doesn't block
835 // on rendering every prior turn before showing anything.
836 if len(i.view.Messages) > initialResumeTailLimit {
837 i.view.TailLimit = initialResumeTailLimit
838 }
839 }
840 i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle)
841 if cfg.InitialSessionTitlePending {
842 i.sessionTitle = ""
843 i.titleRealPromptSeen = false
844 i.titleGenerationStarted = false
845 } else {
846 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
847 i.titleGenerationStarted = i.titleRealPromptSeen
848 }
849 if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
850 i.applyAutoSubagentsTool(true)
851 }
852 return i
853}
854
855// Run blocks until the user quits.
856func (i *Interactive) Run(ctx context.Context) error {
857 i.runCtx = ctx
858 term := i.cfg.Terminal
859 restore, err := term.EnterRaw()
860 if err != nil {
861 return err
862 }
863 defer restore()
864 i.markInteractiveStarted()
865 defer i.cancelSessionTitle()
866 defer i.sessionDialog.Close()
867 defer func() {
868 if i.telegramBridge != nil {
869 i.telegramBridge.Stop()
870 }
871 }()
872
873 // Enabling mouse reporting steals click-drag selection from the
874 // host terminal (VS Code, Ghostty, iTerm). The user prefers native
875 // selection over the wheel-speed boost, so we no longer turn it
876 // on automatically. Wheel events fall through to the terminal's
877 // own scrollback handler.
878 // Keep zut on the terminal's main screen. We intentionally do not
879 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
880 // chat as normal terminal flow/scrollback and redraws only the live
881 // input/status block on normal typing.
882 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
883 // Tell the terminal our working directory (OSC 7) so "new tab /
884 // split here" opens in the launch cwd instead of inheriting a stale
885 // directory from an extension subprocess (see issue #38). Harmless
886 // on terminals that ignore it.
887 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
888 _, _ = term.Write([]byte(seq))
889 }
890 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
891 i.applyInputCursorColor()
892
893 // Streaming pacer: drains buffered text deltas at a steady rate
894 // so typewriter feel is identical across providers regardless of
895 // upstream chunk size. Starts here so it lives for the whole
896 // session and exits with ctx.
897 go i.runStreamPacer(ctx)
898
899 cols, rows := term.Size()
900 i.rend.Resize(cols, rows)
901 term.OnResize(func() {
902 c, r := term.Size()
903 i.rend.Resize(c, r)
904 // Force an immediate redraw on resize. The throttled invalidate
905 // path is fine for animation, but a window resize is a discrete
906 // user action where any visible delay (or stale frame) reads as
907 // brokenness. redraw() is mutex-safe; the worst that happens is
908 // a duplicate paint if the throttler is mid-flight, which is
909 // invisible.
910 i.redraw()
911 })
912
913 if i.cfg.StartupPre != "" {
914 i.deferredInitialInput = i.cfg.InitialInput
915 i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
916 i.awaitingStartupPre = true
917 i.Submit(i.cfg.StartupPre)
918 } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
919 i.Submit(i.cfg.InitialInput)
920 } else if i.cfg.InitialInput != "" {
921 i.ed.SetValue(i.cfg.InitialInput)
922 }
923
924 // Stamp the welcome time and schedule a one-shot redraw at the
925 // expiry so the version suffix disappears on its own even if the
926 // user hasn't typed anything yet.
927 i.welcomeStart = time.Now()
928 time.AfterFunc(welcomeVersionDuration, i.invalidate)
929
930 // If the agent was constructed with a pre-loaded transcript
931 // (--continue, --resume, --session) pin the viewport at the
932 // bottom so the most recent reply (and any prompt the user just
933 // typed) is fully visible. Earlier behaviour parked the view at
934 // the last user turn, which could leave the latest message clipped
935 // off the bottom of the page on long sessions.
936 if i.agent != nil {
937 if msgs := i.agent.Messages(); len(msgs) > 0 {
938 i.scrollToBottom()
939 }
940 }
941
942 // No credential at startup? Auto-open the login dialog, and mark
943 // the status line. The user can Esc out of the dialog if they
944 // want to dismiss it (e.g. to check /help or /exit first).
945 if i.agent == nil {
946 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
947 i.dialog.Open(i.cfg.ZutHome)
948 }
949
950 // Input goroutine. Buffered generously so a drag-drop that the
951 // terminal delivers as a burst of single-character key events
952 // (no bracketed paste) can be drained in one main-loop pass
953 // instead of triggering a redraw per character.
954 keys := make(chan tui.Key, 256)
955 go func() {
956 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
957 for {
958 k, err := reader.Read()
959 if err != nil {
960 return
961 }
962 keys <- k
963 }
964 }()
965
966 // Subscribe to auth events.
967 var authEvents <-chan auth.Event
968 if i.cfg.AuthManager != nil {
969 authEvents = i.cfg.AuthManager.Events()
970 }
971
972 // Animation ticker: drives spinner and dialog-related redraws when
973 // nothing else changed. 120ms is slow enough that highlighting a huge
974 // transcript doesn't spin the cpu.
975 tick := time.NewTicker(120 * time.Millisecond)
976 defer tick.Stop()
977
978 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
979 // at most once every redrawMinInterval. Huge tool-result dumps can
980 // fire hundreds of invalidations while the user is typing; without
981 // this, the input goroutine never gets CPU and keystrokes lag.
982 const redrawMinInterval = 16 * time.Millisecond
983 var lastRedraw time.Time
984 var pendingRedraw bool
985 var pendingTimer *time.Timer
986
987 drainPending := func() {
988 if pendingTimer != nil {
989 pendingTimer.Stop()
990 pendingTimer = nil
991 }
992 if pendingRedraw {
993 pendingRedraw = false
994 lastRedraw = time.Now()
995 i.redraw()
996 }
997 }
998
999 requestRedraw := func() {
1000 since := time.Since(lastRedraw)
1001 if since >= redrawMinInterval {
1002 // Redrawing right now subsumes any pending redraw, so clear
1003 // the throttle state. Without this, a pending flag stays
1004 // stuck at true and subsequent invalidate() calls within
1005 // redrawMinInterval get dropped — which is exactly how the
1006 // final "turn finished" frame went missing until the user
1007 // nudged the ui by typing or scrolling.
1008 if pendingTimer != nil {
1009 pendingTimer.Stop()
1010 }
1011 pendingRedraw = false
1012 lastRedraw = time.Now()
1013 i.redraw()
1014 return
1015 }
1016 if pendingRedraw {
1017 return // already scheduled
1018 }
1019 pendingRedraw = true
1020 wait := redrawMinInterval - since
1021 if pendingTimer == nil {
1022 pendingTimer = time.AfterFunc(wait, func() {
1023 // Poke the dirty channel so the main loop wakes and
1024 // drains the pending redraw on its own goroutine. We
1025 // can't call drainPending here directly — it touches
1026 // closure state shared with the main loop.
1027 i.invalidate()
1028 })
1029 } else {
1030 pendingTimer.Reset(wait)
1031 }
1032 }
1033
1034 i.invalidate()
1035
1036 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
1037 changelog := i.cfg.ChangelogChan // single-shot, see case below
1038
1039 for {
1040 select {
1041 case <-ctx.Done():
1042 return ctx.Err()
1043 case k := <-keys:
1044 if done := i.handleKey(ctx, k); done {
1045 return nil
1046 }
1047 // Drain any keystrokes that arrived during this iteration.
1048 // VS Code (and other terminals that don't bracket drops as
1049 // paste) deliver a path one rune at a time — without this
1050 // loop the editor would render between every rune and a
1051 // long path on a heavy transcript would visibly type in.
1052 drain:
1053 for {
1054 select {
1055 case k2 := <-keys:
1056 if done := i.handleKey(ctx, k2); done {
1057 return nil
1058 }
1059 default:
1060 break drain
1061 }
1062 }
1063 i.invalidate()
1064 case ev := <-authEvents:
1065 i.handleAuthEvent(ev)
1066 i.invalidate()
1067 case result := <-i.modelRefresh:
1068 i.mu.Lock()
1069 i.modelRefreshing = false
1070 i.mu.Unlock()
1071 i.openModelPickerAfterRefresh(result.err)
1072 i.invalidate()
1073 case result := <-i.startupPreDone:
1074 i.applyStartupPreResult(result)
1075 i.invalidate()
1076 case info, ok := <-updates:
1077 if ok && info.Available {
1078 i.mu.Lock()
1079 i.updateInfo = info
1080 i.mu.Unlock()
1081 i.invalidate()
1082 }
1083 updates = nil // single-shot; subsequent iterations skip this case
1084 case cl, ok := <-changelog:
1085 if ok && cl.Body != "" {
1086 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
1087 i.invalidate()
1088 }
1089 changelog = nil // single-shot
1090 case event, ok := <-i.sessionLoads:
1091 i.mu.Lock()
1092 if !ok {
1093 i.sessionLoads = nil
1094 i.sessionDialog.ApplyLoadClosed()
1095 i.mu.Unlock()
1096 i.invalidate()
1097 break
1098 }
1099 i.sessionDialog.ApplyLoad(event)
1100 i.mu.Unlock()
1101 i.invalidate()
1102 case <-i.dirty:
1103 requestRedraw()
1104 case <-tick.C:
1105 // Always drain a pending redraw on the tick. This is the
1106 // safety net that catches the case where the dirty channel
1107 // was saturated when the final "turn finished" invalidate
1108 // fired, or where the throttle scheduled a deferred redraw
1109 // and the AfterFunc-driven invalidate got dropped on a
1110 // full channel.
1111 drainPending()
1112 // Only force a periodic redraw when something is actually
1113 // animating (the main spinner during a busy turn, or the
1114 // btw side-chat spinner while it's awaiting a response).
1115 // Static pickers (model, completed session, jump, etc.) don't
1116 // need the tick and firing it cancels the terminal's cursor
1117 // blink inside dialogs that host their own editor (btw),
1118 // because each frame re-emits hide-cursor + show-cursor.
1119 // A session picker that is still loading is an exception so
1120 // its dialog-local spinner can advance.
1121 //
1122 // The subagent dashboard is also animated: its rows reflect
1123 // background subprocesses whose activity / age change
1124 // without any user input. Without the tick redraw the
1125 // dashboard freezes on the snapshot taken when the user
1126 // last pressed a key. We exclude the dashboard when one
1127 // of its inline editors (spawn task or prompt composer)
1128 // is active so the cursor blink in those editors works
1129 // the same way it does inside btw.
1130 i.mu.Lock()
1131 busy := i.busy
1132 sessionLoading := i.sessionDialog.Loading()
1133 i.mu.Unlock()
1134 if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1135 requestRedraw()
1136 }
1137 }
1138 }
1139}
1140
1141func (i *Interactive) invalidate() {
1142 select {
1143 case i.dirty <- struct{}{}:
1144 default:
1145 }
1146}
1147
1148func (i *Interactive) cachedChatLocked(cols int) []string {
1149 key, cacheable := i.chatCacheKeyLocked(cols)
1150 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
1151 return append([]string(nil), i.chatCache...)
1152 }
1153 chat := i.buildChatLocked(cols)
1154 if cacheable {
1155 i.chatCache = append(i.chatCache[:0], chat...)
1156 i.chatCacheKey = key
1157 i.chatCacheValid = true
1158 } else {
1159 i.chatCacheValid = false
1160 }
1161 return chat
1162}
1163
1164func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1165 // Live turns mutate streaming/tool-call state at high frequency;
1166 // keep those on the old rebuild path. The cache targets the common
1167 // idle case where only the editor contents changed between redraws.
1168 if i.busy || i.streamOn || i.streamFlushPending {
1169 return chatCacheKey{}, false
1170 }
1171 var rev uint64
1172 if i.agent != nil {
1173 rev = i.agent.Revision()
1174 }
1175 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1176 return chatCacheKey{
1177 cols: cols,
1178 agentRev: rev,
1179 statusOK: i.statusOK,
1180 statusErr: i.statusErr,
1181 help: strings.Join(i.helpBlock, "\n"),
1182 extNotes: strings.Join(i.extNotes, "\n"),
1183 extStatuses: i.extensionStatusesKeyLocked(),
1184 extWidgets: i.extensionWidgetsKeyLocked(),
1185 reloadErrors: strings.Join(i.reloadErrors, "\n"),
1186 updateAvailable: i.updateInfo.Available,
1187 updateCurrent: i.updateInfo.Current,
1188 updateLatest: i.updateInfo.Latest,
1189 updateURL: i.updateInfo.URL,
1190 welcomeShowVer: showVer,
1191 expandAll: i.view.ExpandAll,
1192 tailLimit: i.view.TailLimit,
1193 }, true
1194}
1195
1196func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
1197 keys := make([]string, 0, len(groups))
1198 for key := range groups {
1199 keys = append(keys, key)
1200 }
1201 sort.Strings(keys)
1202 return keys
1203}
1204
1205func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
1206 items := groups[outer]
1207 keys := make([]string, 0, len(items))
1208 for key := range items {
1209 keys = append(keys, key)
1210 }
1211 sort.Strings(keys)
1212 return keys
1213}
1214
1215func (i *Interactive) extensionStatusesKeyLocked() string {
1216 var sb strings.Builder
1217 extNames := sortedNestedOuterKeys(i.extStatuses)
1218 for _, extName := range extNames {
1219 keys := sortedNestedInnerKeys(i.extStatuses, extName)
1220 for _, key := range keys {
1221 status := i.extStatuses[extName][key]
1222 fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
1223 }
1224 }
1225 return sb.String()
1226}
1227
1228func (i *Interactive) extensionWidgetsKeyLocked() string {
1229 var sb strings.Builder
1230 extNames := sortedNestedOuterKeys(i.extWidgets)
1231 for _, extName := range extNames {
1232 ids := sortedNestedInnerKeys(i.extWidgets, extName)
1233 for _, id := range ids {
1234 widget := i.extWidgets[extName][id]
1235 fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
1236 for _, line := range widget.Lines {
1237 sb.WriteString(line)
1238 sb.WriteByte('\n')
1239 }
1240 }
1241 }
1242 return sb.String()
1243}
1244
1245const maxExtensionWidgetRows = 12
1246const maxExtensionStatusRows = 6
1247
1248// maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back
1249// above the input, leaving one row for the truncation marker.
1250const maxNarrowExtensionChromeRows = 6
1251
1252func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1253 var out []tui.RightBarWidget
1254 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1255 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1256 widget := i.extWidgets[extName][id]
1257 if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1258 continue
1259 }
1260 out = append(out, tui.RightBarWidget{
1261 Extension: extName,
1262 ID: id,
1263 Title: widget.Title,
1264 Lines: append([]string(nil), widget.Lines...),
1265 })
1266 }
1267 }
1268 return out
1269}
1270
1271func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1272 return i.extensionChromeLinesAtLocked(cols, false)
1273}
1274
1275func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1276 lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1277 if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1278 return lines
1279 }
1280 limit := maxNarrowExtensionChromeRows
1281 trimmed := append([]string(nil), lines[:limit-1]...)
1282 trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1283 return trimmed
1284}
1285
1286func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1287 var out []string
1288 bodyWidth := cols - 2
1289 if bodyWidth < 1 {
1290 bodyWidth = 1
1291 }
1292 widgetRows := 0
1293 widgetTruncated := false
1294 appendWidgetRow := func(line string) bool {
1295 if widgetRows >= maxExtensionWidgetRows-1 {
1296 widgetTruncated = true
1297 return false
1298 }
1299 out = append(out, line)
1300 widgetRows++
1301 return true
1302 }
1303
1304 stopWidgets := false
1305 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1306 if stopWidgets {
1307 break
1308 }
1309 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1310 widget := i.extWidgets[extName][id]
1311 if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1312 continue
1313 }
1314 label := " [" + extName + "]"
1315 if widget.Title != "" {
1316 label += " " + widget.Title
1317 }
1318 if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1319 stopWidgets = true
1320 break
1321 }
1322 for _, line := range widget.Lines {
1323 if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1324 stopWidgets = true
1325 break
1326 }
1327 }
1328 if stopWidgets {
1329 break
1330 }
1331 }
1332 }
1333 if widgetTruncated {
1334 out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1335 }
1336
1337 statusRows := 0
1338 statusTruncated := false
1339 for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1340 for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1341 if statusRows >= maxExtensionStatusRows-1 {
1342 statusTruncated = true
1343 break
1344 }
1345 status := i.extStatuses[extName][key]
1346 color := i.cfg.Theme.Muted
1347 switch status.Level {
1348 case "warn":
1349 color = i.cfg.Theme.Warning
1350 case "error":
1351 color = i.cfg.Theme.Error
1352 case "success":
1353 color = i.cfg.Theme.Tool
1354 }
1355 label := " [" + extName + "] " + status.Text
1356 out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
1357 statusRows++
1358 }
1359 if statusTruncated {
1360 break
1361 }
1362 }
1363 if statusTruncated {
1364 out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1365 }
1366 return out
1367}
1368
1369func (i *Interactive) buildChatLocked(cols int) []string {
1370 if i.agent != nil {
1371 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1372 } else {
1373 i.view.Messages = nil
1374 }
1375 // Pacer flush: while the streaming pacer is still draining the
1376 // buffer (i.e. EvAssistantMessage already fired but more runes
1377 // are queued), the final assistant message is already in
1378 // i.agent.Messages() in full. Painting it in the transcript
1379 // AND the streaming block at the same time shows the user the
1380 // complete text immediately — which defeats the whole pacer.
1381 // Hide the last message until the pacer catches up; once the
1382 // flush-pending latch clears, the message is revealed (the
1383 // streaming block disappears the same frame because streamOn
1384 // flips off, so the transition is seamless).
1385 if i.streamFlushPending && len(i.view.Messages) > 0 {
1386 i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
1387 }
1388 i.view.Streaming = i.streaming.String()
1389 i.view.StreamingActive = i.streamOn
1390 // Guard against the narrow race where EvAssistantMessage has
1391 // just promoted a streaming reply into the transcript but a
1392 // render tick hasn't flipped streamOn off yet. Without the
1393 // guard, the same text would appear twice (once as the
1394 // in-flight streaming block, once as the last transcript
1395 // message). We detect the duplicate strictly: the last
1396 // assistant message's visible text must equal the streaming
1397 // buffer. Just matching on role is too broad — it also hides
1398 // the next round's typewriter streaming after a tool turn,
1399 // because the last transcript message is always assistant
1400 // (the tool-use block) until the follow-up summary lands.
1401 if i.streamOn && i.streaming.Len() > 0 {
1402 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
1403 if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
1404 i.view.StreamingActive = false
1405 }
1406 }
1407 }
1408 // Live tool-call view: only shown while a turn is in flight. Once
1409 // the agent is idle, every tool call has already been folded into
1410 // the transcript (as assistant.ToolCallBlock + a tool-role message),
1411 // so showing v.ToolCalls a second time would duplicate them below
1412 // the final assistant text — which looks like the summary came
1413 // "before" the tools.
1414 i.view.ToolCalls = i.view.ToolCalls[:0]
1415 if i.busy {
1416 for _, id := range i.toolOrder {
1417 // Deterministic ordering: a tool block stays hidden until
1418 // the paced assistant text that preceded it has finished
1419 // typing out. toolOrder is append-only in arrival order,
1420 // so once one tool is still gated, every later tool is too,
1421 // so stop here to avoid showing a tool out of sequence.
1422 if !i.toolGateOpenLocked(id) {
1423 break
1424 }
1425 if tc, ok := i.toolCalls[id]; ok {
1426 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
1427 }
1428 }
1429 }
1430 i.view.Err = i.statusErr
1431 // Live streaming/tool rows are appended to the chat buffer (not
1432 // hoisted into a separate live block above the editor). That keeps
1433 // the renderer's diff view append-only: when a tool finishes the
1434 // rows update in place at the end of the buffer, instead of the
1435 // whole bottom band shrinking and shifting chat lines around.
1436 i.liveBlock = nil
1437 chat := i.view.Build(cols)
1438
1439 // Welcome banner: shown at the top of the chat area when there is
1440 // no transcript yet. Disappears after the first message is sent.
1441 // The version suffix is shown for welcomeVersionDuration after
1442 // startup, then drops off automatically.
1443 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
1444 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1445 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
1446 }
1447
1448 // Update-available banner: prepended above everything else so it's
1449 // the first thing the user sees when opening a new zut session.
1450 // Once rendered, it stays until the user updates to a newer
1451 // version — we don't persist a "dismissed" flag because this is
1452 // cheap and re-showing it is how most users remember to update.
1453 if i.updateInfo.Available {
1454 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
1455 chat = append(banner, chat...)
1456 }
1457
1458 // /help block: appended to the transcript so it appears at the
1459 // bottom of the chat area (right above the status bar / editor).
1460 // Prepending it would push long conversations off the top of the
1461 // viewport, which users would miss entirely.
1462 if len(i.helpBlock) > 0 {
1463 chat = append(chat, i.helpBlock...)
1464 }
1465
1466 if i.statusOK != "" {
1467 // Hard-truncate the OK line to the visible width so a long
1468 // session path ("resumed session: /Users/.../sessions/...")
1469 // doesn't overflow past the right edge and look broken on a
1470 // narrow terminal.
1471 line := "✓ " + i.statusOK
1472 if cols > 4 && len(line) > cols {
1473 line = line[:cols-3] + "..."
1474 }
1475 chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
1476 }
1477
1478 // Live shell-escape output (!command / entry.pre) streams into the
1479 // transcript area while the command runs, then is replaced by the
1480 // final user-context message when it finishes.
1481 if i.shellRunning && i.shellLive != "" {
1482 chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
1483 chat = append(chat, "")
1484 }
1485
1486 // Extension notes (notify / display) live just under the
1487 // transcript, above the dialog/editor band. Cleared by /clear.
1488 if len(i.extNotes) > 0 {
1489 chat = append(chat, i.extNotes...)
1490 chat = append(chat, "")
1491 }
1492
1493 // Reload failures remain in the scrolling chat until /clear. They are
1494 // host-only display state, not provider messages or persisted context.
1495 // While the latest failure is still the temporary status error, hide its
1496 // stored copy to avoid painting the same message twice.
1497 reloadErrors := i.reloadErrors
1498 if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1499 reloadErrors = reloadErrors[:n-1]
1500 }
1501 if len(reloadErrors) > 0 {
1502 chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1503 chat = append(chat, "")
1504 }
1505
1506 // Strip trailing blank rows so the chat content sits flush
1507 // against the new "blank above status bar" row added by the
1508 // bottom-region assembly. Build() ends every message with a
1509 // blank separator; without this trim, the final message in
1510 // the transcript would have its own trailing blank plus the
1511 // status block's leading blank, doubling the gap.
1512 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1513 chat = chat[:len(chat)-1]
1514 }
1515 return chat
1516}
1517
1518// sessionsRoot returns the session namespace for this run.
1519func (i *Interactive) sessionsRoot() string {
1520 if i.cfg.SessionsRoot != "" {
1521 return i.cfg.SessionsRoot
1522 }
1523 return i.cfg.ZutHome
1524}
1525
1526// lastCols returns the current terminal width in columns.
1527func (i *Interactive) lastCols() int {
1528 cols, _ := i.cfg.Terminal.Size()
1529 return cols
1530}
1531
1532// chatPage returns the number of chat rows currently visible, used
1533// as the page size for PageUp/PageDown.
1534func (i *Interactive) chatPage() int {
1535 _, rows := i.cfg.Terminal.Size()
1536 p := rows - 6 // rough reservation for status + editor + a dialog line
1537 if p < 4 {
1538 p = 4
1539 }
1540 return p
1541}
1542
1543// scrollBy adjusts the scroll offset. Positive = up (into history).
1544// Clearing the parked-turn label when we're back at the bottom means
1545// the "viewing turn N" footer goes away automatically as soon as you
1546// scroll back to the live tail.
1547func (i *Interactive) scrollBy(delta int) {
1548 i.mu.Lock()
1549 i.scrollOffset += delta
1550 if i.scrollOffset < 0 {
1551 i.scrollOffset = 0
1552 }
1553 if i.scrollOffset == 0 {
1554 i.parkedTurn = 0
1555 i.parkedTotal = 0
1556 }
1557 if i.rend != nil {
1558 // VS Code's terminal is especially prone to leaving stray
1559 // wrapped-character fragments behind during scroll-driven
1560 // viewport changes. Force a full repaint on scroll, but
1561 // avoid a whole-screen clear because that visibly flickers.
1562 i.rend.Invalidate()
1563 }
1564 i.mu.Unlock()
1565 i.invalidate()
1566}
1567
1568// anchorScrollOffset keeps the user's reading position pinned when the
1569// chat buffer grows/shrinks or the viewport height changes between two
1570// redraws while they're scrolled up.
1571//
1572// scrollOffset is measured from the bottom of the chat buffer, so the
1573// top visible row is start = chatLen - scrollOffset - chatRows. To hold
1574// `start` constant we adjust the offset by the buffer-length delta minus
1575// the viewport-height delta. The result is clamped to [0, newLen] so a
1576// shrinking buffer can't push it negative.
1577func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1578 adj := (newLen - prevLen) - (newRows - prevRows)
1579 offset += adj
1580 if offset < 0 {
1581 offset = 0
1582 }
1583 if offset > newLen {
1584 offset = newLen
1585 }
1586 return offset
1587}
1588
1589// scrollToBottom pins the view to the latest content.
1590func (i *Interactive) scrollToBottom() {
1591 i.mu.Lock()
1592 i.scrollOffset = 0
1593 i.parkedTurn = 0
1594 i.parkedTotal = 0
1595 // Reset the auto-follow baseline. scrollToBottom is the resume /
1596 // session-swap snap point, where the chat buffer changes length
1597 // wholesale. Without zeroing these, the next render's follow guard
1598 // compares the fresh transcript's length against a stale baseline
1599 // and nudges scrollOffset, which reads as a viewport jump right
1600 // after resume. See commit 43da5e5 for the same fix on new turns.
1601 i.prevChatLen = 0
1602 i.prevChatCols = 0
1603 if i.rend != nil {
1604 i.rend.Invalidate()
1605 }
1606 i.mu.Unlock()
1607 i.invalidate()
1608}
1609
1610func (i *Interactive) redraw() {
1611 i.mu.Lock()
1612 defer i.mu.Unlock()
1613
1614 cols, rows := i.cfg.Terminal.Size()
1615 mainCols := cols
1616 rightBarWidgets := i.rightBarWidgetsLocked()
1617 rightBarActive := false
1618 var rightBarWidth int
1619 if !i.rightBarHidden && len(rightBarWidgets) > 0 {
1620 if width, rail, ok := tui.RightBarColumns(cols); ok {
1621 mainCols = width
1622 rightBarWidth = rail
1623 rightBarActive = true
1624 }
1625 }
1626 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1627 chat := i.cachedChatLocked(mainCols)
1628
1629 // Dialogs (login or model picker) render between chat and the editor.
1630 var dialog []string
1631 switch {
1632 case i.dialog.Active():
1633 dialog = i.dialog.Render(i.cfg.Theme, mainCols)
1634 case i.modelDialog.Active():
1635 dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
1636 case i.llamaDialog.Active():
1637 dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
1638 case i.rescueDialog.Active():
1639 dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
1640 case i.sessionDialog.Active():
1641 // Reserve rows for the editor (~3), status line (1-2),
1642 // dialog chrome (header + hint + rule + indicators, ~5),
1643 // and leave the remainder for session rows. Minimum of 3
1644 // rows so even a very small terminal shows something.
1645 _, rows := i.cfg.Terminal.Size()
1646 avail := rows - 12
1647 if avail < 3 {
1648 avail = 3
1649 }
1650 i.sessionDialog.MaxRows = avail
1651 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1652 case i.subagentsDialog.Active():
1653 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1654 case i.jumpDialog.Active():
1655 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
1656 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1657 dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
1658 case i.confirmDialog.Active():
1659 if i.btwDialog.Active() {
1660 // Keep the side-chat transcript and its live tool preview visible
1661 // above confirmation, matching how the main transcript remains
1662 // visible while --no-yolo waits for a decision.
1663 dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
1664 } else {
1665 dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
1666 }
1667 case i.btwDialog.Active():
1668 dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
1669 case i.skillsDialog.Active():
1670 dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
1671 case i.changelogDialog.Active():
1672 dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
1673 case i.logoutDialog.Active():
1674 dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
1675 case i.telegramDialog.Active():
1676 dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
1677 case i.settingsDialog.Active():
1678 dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
1679 case i.sessionOpsDialog.Active():
1680 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
1681 case i.sessionTreeDialog.Active():
1682 // Reserve rows for the editor, status, and tree chrome while using
1683 // the same budget for rendering and PageUp/PageDown movement.
1684 _, rows := i.cfg.Terminal.Size()
1685 avail := rows - 12
1686 if avail < 3 {
1687 avail = 3
1688 }
1689 i.sessionTreeDialog.MaxRows = avail
1690 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
1691 }
1692 if len(dialog) > 0 {
1693 dialog = padDialogFrame(dialog)
1694 }
1695 modalBackdrop := len(dialog) > 0
1696
1697 // Slash-command autocomplete: popup above the status line, only
1698 // when the editor starts with "/" and no dialog is already open.
1699 // Feed extension-registered commands into the suggester first so
1700 // they show up in tab-complete + the popup alongside the built-ins.
1701 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
1702 i.suggest.SetLlamaConfigured(i.llamaConfigured)
1703 if i.cfg.Extensions != nil {
1704 catalog := i.cfg.Extensions.Commands()
1705 extra := make([]slashCommand, 0, len(catalog))
1706 for _, c := range catalog {
1707 // The popup renders extension commands under a dedicated
1708 // "── extensions ───" divider, so the description doesn't
1709 // need to repeat the source. If the description is empty,
1710 // fall back to the extension name so the row isn't blank.
1711 desc := c.Description
1712 if strings.TrimSpace(desc) == "" {
1713 desc = "(" + c.Extension + ")"
1714 }
1715 extra = append(extra, slashCommand{
1716 Name: "/" + c.Name,
1717 Desc: desc,
1718 })
1719 }
1720 i.suggest.SetExtra(extra)
1721 }
1722 var suggest []string
1723 currentInput := i.ed.Value()
1724 if i.suggest.SkillInputStarted(currentInput) {
1725 var list []*skills.Skill
1726 if i.cfg.SkillSnapshot != nil {
1727 list = i.cfg.SkillSnapshot()
1728 }
1729 i.suggest.SetSkills(list)
1730 }
1731 // Slash popup renders even while the agent is busy so the user
1732 // can queue a destructive command (/clear, /compact, /logout,
1733 // /model) or a read-only one (/help, /jump, /sessions, etc.)
1734 // without waiting for the current turn to finish. The dispatcher
1735 // in runSlash already handles the busy case per-command: safe
1736 // ones run immediately, destructive ones cancel the turn first.
1737 i.fileSuggest.SetCWD(i.cfg.CWD)
1738 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
1739 if mainInputFocused && i.suggest.Active(currentInput) {
1740 suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
1741 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1742 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
1743 }
1744
1745 // Detect overlay close (any dialog or slash/file suggestion popup
1746 // just transitioned from open to closed). Force a full redraw so
1747 // the rows the overlay occupied are guaranteed to be repainted
1748 // from the chat below, instead of the diff path leaving stale
1749 // dialog content behind. Equivalent to the user pressing ctrl+l.
1750 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1751 if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
1752 // An overlay (dialog or slash/file popup) just closed, so the
1753 // bottom band shrinks. On terminals where we can drop
1754 // scrollback, a full Clear is the simplest way to guarantee
1755 // the vacated rows are repainted from the chat below.
1756 //
1757 // On VS Code's terminal closing a dialog leaves the stale
1758 // overlay rows in the retained scrollback (we can't drop them
1759 // with the quiet in-place diff). Run the same full Clear() that
1760 // Ctrl+L uses so the scrollback is purged and the conversation
1761 // is repainted clean, matching what the user expects after
1762 // dismissing a picker. Clear() is keepScrollback-aware and
1763 // emits \x1b[3J there.
1764 i.rend.Clear()
1765 }
1766 i.prevOverlayOpen = overlayOpen
1767 if len(suggest) > 0 {
1768 // One blank row above the popup so it doesn't sit flush
1769 // against the chat / welcome content above.
1770 suggest = append([]string{""}, suggest...)
1771 }
1772
1773 // Busy prefix shown at the far left of the status bar. The spinner owns
1774 // only its frame; the activity label reports the operation in progress.
1775 var busyPrefix string
1776 if i.busy {
1777 label := i.activity.label()
1778 if label == "" {
1779 label = "Preparing request"
1780 }
1781 busyPrefix = fmt.Sprintf("%s %s %s %s",
1782 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1783 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
1784 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
1785 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1786 )
1787 }
1788
1789 ctxMax := 0
1790 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1791 ctxMax = m.ContextWindow
1792 }
1793 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1794 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1795 workingWithStatus := statusPosition == workingPosition
1796 statusBusyPrefix := ""
1797 if workingWithStatus {
1798 statusBusyPrefix = busyPrefix
1799 }
1800 statusLines := tui.StatusBar(tui.StatusBarParams{
1801 Theme: i.cfg.Theme,
1802 Provider: i.cfg.Provider,
1803 Model: i.cfg.Model,
1804 Reasoning: i.cfg.Reasoning,
1805 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
1806 Busy: i.busy,
1807 BusyPrefix: statusBusyPrefix,
1808 CWD: i.cfg.CWD,
1809 Locked: i.cfg.Sandbox.Locked(),
1810 NoYolo: i.cfg.NoYolo,
1811 Usage: i.cumUsage,
1812 Subscription: i.cfg.AuthMethod == "oauth",
1813 ContextUsed: i.lastCtxInput,
1814 ContextMax: ctxMax,
1815 AutoCompacting: i.autoCompacting,
1816 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1817 Cols: mainCols,
1818 })
1819 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
1820 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
1821 i.ed.Prompt = ""
1822 } else {
1823 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1824 }
1825 edLines, curR, curC := i.ed.Render(mainCols)
1826 var workingLines []string
1827 if busyPrefix != "" && !workingWithStatus {
1828 workingLines = []string{" " + busyPrefix}
1829 }
1830 inputCursorOffset := 0
1831 inputCursorColOffset := 0
1832 switch inputStyle {
1833 case tui.InputStyleLines:
1834 edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
1835 inputCursorOffset = 1
1836 case tui.InputStyleBlock:
1837 edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
1838 inputCursorOffset = 1
1839 inputCursorColOffset = 2
1840 }
1841
1842 // "Sliding in" chips for messages the user typed while a turn is
1843 // in flight. Shown directly above the status bar so they're close
1844 // to the editor but don't push the chat around.
1845 var queue []string
1846 queued := append([]string(nil), i.queued...)
1847 if i.agent != nil {
1848 queued = append(queued, i.agent.PendingQueuedMessages()...)
1849 }
1850 if len(queued) > 0 {
1851 queue = append(queue, "")
1852 for _, q := range queued {
1853 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
1854 text := truncateLine(q, mainCols-17)
1855 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
1856 }
1857 // Hint row, rendered in the same muted tone as the model
1858 // info on the status bar so it reads as ambient metadata
1859 // rather than a chip. Tells the user how to recover the
1860 // most recent queued message back into the editor.
1861 hint := " Press " + slideBackChordHint() + " to slide back into input"
1862 queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
1863 }
1864
1865 extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
1866
1867 // Bottom-sticky sections (always visible, never scroll). Each
1868 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1869 // is preceded by one blank row so it has air above the chat
1870 // content. The status block and editor get their own dedicated
1871 // blanks so spacing stays consistent whether or not a dialog or
1872 // popup is showing.
1873 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
1874 inputStartRow := -1
1875 if len(dialog) > 0 {
1876 bottom = append(bottom, "")
1877 }
1878 bottom = append(bottom, dialog...)
1879 // The subagent dashboard owns the bottom of the screen while it's
1880 // active: it has its own inline editors for spawn (`n`) and
1881 // prompt (`p`), so the main input would be a confusing second
1882 // caret. The suggest popup, sliding-in queue, status block, and
1883 // main editor are all hidden underneath it. Keystrokes still
1884 // reach handleKey — it routes them to subagentsDialog.HandleKey
1885 // before the editor ever sees them — so the only effect of this
1886 // branch is visual.
1887 if !i.subagentsDialog.Active() {
1888 bottom = append(bottom, suggest...)
1889 bottom = append(bottom, queue...)
1890 lineInput := inputStyle == tui.InputStyleLines
1891 statusBelow := statusPosition == tui.StatusPositionBelowInput
1892 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1893
1894 var aboveInput []string
1895 aboveInput = append(aboveInput, extensionLines...)
1896 if !statusBelow {
1897 aboveInput = append(aboveInput, statusLines...)
1898 }
1899 if !workingBelow {
1900 aboveInput = append(aboveInput, workingLines...)
1901 }
1902 var belowInput []string
1903 if workingBelow {
1904 belowInput = append(belowInput, workingLines...)
1905 }
1906 if statusBelow {
1907 belowInput = append(belowInput, statusLines...)
1908 }
1909
1910 bottom = append(bottom, "")
1911 bottom = append(bottom, aboveInput...)
1912 needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1913 if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1914 needsGapAboveInput = true
1915 }
1916 if needsGapAboveInput {
1917 bottom = append(bottom, "")
1918 }
1919 inputStartRow = len(bottom)
1920 bottom = append(bottom, edLines...)
1921 if len(belowInput) > 0 {
1922 if !lineInput {
1923 bottom = append(bottom, "")
1924 }
1925 bottom = append(bottom, belowInput...)
1926 }
1927 }
1928
1929 chatRows := rows - len(bottom)
1930 if rightBarActive {
1931 // DrawRightBar keeps the renderer-owned bottom margin inside the
1932 // fixed frame, so reserve that row before selecting visible chat.
1933 chatRows--
1934 }
1935 if chatRows < 1 {
1936 chatRows = 1
1937 }
1938
1939 // Auto-follow guard: when the user has scrolled up (scrollOffset
1940 // > 0) and the agent appends new content below the viewport while
1941 // they're reading, compensate so the visible content stays
1942 // anchored. scrollOffset is measured from the bottom of `chat`,
1943 // so without compensation a growing buffer pushes the window
1944 // downward through the content and the lines the user was
1945 // reading scroll off the top.
1946 //
1947 // Skip compensation when the terminal width changed (a resize
1948 // reflows the whole buffer and the line-count delta no longer
1949 // corresponds to appended content) and when scrollOffset is 0
1950 // (the user is following the tail and wants new content to push
1951 // the view down as usual).
1952 //
1953 // The window the user sees starts at row
1954 // start = len(chat) - scrollOffset - chatRows
1955 // so to keep `start` fixed across a redraw we must offset by both
1956 // the buffer growth (len delta) AND the viewport-height change
1957 // (chatRows delta, e.g. the status band or sliding-in queue
1958 // appearing during a turn). Compensating only for the len delta
1959 // let a shrinking chatRows pull the window toward the tail, which
1960 // read as the viewport jumping to the bottom whenever the agent
1961 // streamed text or a tool call grew the bottom band.
1962 if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
1963 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1964 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1965 }
1966 i.prevChatLen = len(chat)
1967 i.prevChatCols = mainCols
1968 i.prevChatRows = chatRows
1969
1970 // Apply scroll offset to the chat slice.
1971 maxOffset := len(chat) - chatRows
1972 if maxOffset < 0 {
1973 maxOffset = 0
1974 }
1975 // Tail-render expansion: if the user has scrolled to (or above)
1976 // the top of the currently rendered tail and there are still
1977 // truncated messages above, widen view.TailLimit and rebuild.
1978 // The chat cache is keyed on tailLimit so the next cachedChatLocked
1979 // will re-issue Build instead of returning the stale slice. We
1980 // rebuild immediately so the same redraw shows the freshly-revealed
1981 // rows; otherwise the user would have to scroll again to see them.
1982 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1983 prevLen := len(chat)
1984 i.view.TailLimit += resumeTailExpandStep
1985 if i.view.TailLimit >= len(i.view.Messages) {
1986 i.view.TailLimit = 0 // unbounded
1987 }
1988 i.chatCacheValid = false
1989 chat = i.cachedChatLocked(mainCols)
1990 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1991 chat = chat[:len(chat)-1]
1992 }
1993 // Newly-revealed rows are older messages prepended at the top.
1994 // scrollOffset counts rows from the bottom, so to keep the
1995 // viewport visually anchored on the same content the user was
1996 // looking at we shift it up by the number of rows added. Keep
1997 // the auto-follow baseline (prevChatLen) in sync with the
1998 // post-expansion length too, so the next render's follow guard
1999 // doesn't see this growth as a synthetic delta and yank the
2000 // viewport again.
2001 if grew := len(chat) - prevLen; grew > 0 {
2002 i.scrollOffset += grew
2003 }
2004 i.prevChatLen = len(chat)
2005 i.prevChatCols = mainCols
2006 maxOffset = len(chat) - chatRows
2007 if maxOffset < 0 {
2008 maxOffset = 0
2009 }
2010 }
2011 if i.scrollOffset > maxOffset {
2012 i.scrollOffset = maxOffset
2013 }
2014 if i.scrollOffset < 0 {
2015 i.scrollOffset = 0
2016 }
2017
2018 var visibleChat []string
2019 if len(chat) <= chatRows {
2020 visibleChat = chat
2021 } else {
2022 end := len(chat) - i.scrollOffset
2023 rawStart := end - chatRows
2024 if rawStart < 0 {
2025 rawStart = 0
2026 }
2027 start := snapViewportStartToImageBlock(chat, rawStart)
2028 // If the snap pulled start upward (an image-block was atomic) while
2029 // the user is scrolling downward, the viewport would sit on the same
2030 // image until the user mashes down past every reserved row. Bump
2031 // scrollOffset past the image so one keypress always clears it.
2032 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
2033 jump := rawStart - start
2034 i.scrollOffset -= jump
2035 if i.scrollOffset < 0 {
2036 i.scrollOffset = 0
2037 }
2038 end = len(chat) - i.scrollOffset
2039 rawStart = end - chatRows
2040 if rawStart < 0 {
2041 rawStart = 0
2042 }
2043 start = snapViewportStartToImageBlock(chat, rawStart)
2044 }
2045 end = start + chatRows
2046 if end > len(chat) {
2047 end = len(chat)
2048 start = end - chatRows
2049 if start < 0 {
2050 start = 0
2051 }
2052 }
2053 visibleChat = chat[start:end]
2054 }
2055 i.prevScrollOffset = i.scrollOffset
2056 visibleChat = clipBottomClippedImages(visibleChat)
2057
2058 // A tiny "scrolled up" indicator in the top-right of the chat pane
2059 // so you know you're not at the bottom. When the viewport was
2060 // parked by /jump we include the turn number so the user remembers
2061 // they're reading history rather than the live conversation.
2062 if i.scrollOffset > 0 && len(visibleChat) > 0 {
2063 var text string
2064 if i.parkedTurn > 0 && i.parkedTotal > 0 {
2065 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
2066 i.parkedTurn, i.parkedTotal, i.scrollOffset)
2067 } else {
2068 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
2069 }
2070 note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
2071 visibleChat = append([]string{note}, visibleChat...)
2072 if len(visibleChat) > chatRows {
2073 visibleChat = visibleChat[:chatRows]
2074 }
2075 }
2076
2077 // A dialog is the foreground layer. Dim every other visible surface,
2078 // including the main input beneath it, without changing the dialog's
2079 // own styling. Keep the source slices untouched because chat is cached.
2080 if modalBackdrop {
2081 chat = tui.DimLines(chat)
2082 visibleChat = tui.DimLines(visibleChat)
2083 dimmedBottom := tui.DimLines(bottom)
2084 copy(dimmedBottom[1:1+len(dialog)], dialog)
2085 bottom = dimmedBottom
2086 }
2087
2088 // Default: the real terminal cursor sits on the main editor's
2089 // input position. In main-screen log mode cursor rows are relative
2090 // to the fixed bottom band, not the chat transcript.
2091 // dialogLead is 1 when the bottom region prepends a blank above
2092 // the dialog block (whenever a dialog is showing) so popup-side
2093 // cursor positions still land in the right cell.
2094 dialogLead := 0
2095 if len(dialog) > 0 {
2096 dialogLead = 1
2097 }
2098 cursorRow := inputStartRow + inputCursorOffset + curR
2099 cursorCol := curC + inputCursorColOffset
2100 cursorInDialog := false
2101 if i.btwDialog.Active() {
2102 if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
2103 cursorRow = dialogLead + r
2104 cursorCol = c
2105 cursorInDialog = true
2106 }
2107 }
2108 if i.dialog.Active() {
2109 if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2110 cursorRow = dialogLead + r
2111 cursorCol = c
2112 cursorInDialog = true
2113 }
2114 }
2115 if i.llamaDialog.Active() {
2116 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
2117 cursorRow = dialogLead + r
2118 cursorCol = c
2119 cursorInDialog = true
2120 } else {
2121 cursorRow = -1
2122 cursorCol = 0
2123 }
2124 }
2125 if i.sessionDialog.Active() {
2126 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
2127 cursorRow = dialogLead + r
2128 cursorCol = c
2129 cursorInDialog = true
2130 }
2131 }
2132 if i.sessionTreeDialog.Active() {
2133 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
2134 cursorRow = dialogLead + r
2135 cursorCol = c
2136 cursorInDialog = true
2137 }
2138 }
2139 if i.subagentsDialog.Active() {
2140 if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
2141 cursorRow = dialogLead + r
2142 cursorCol = c
2143 cursorInDialog = true
2144 } else {
2145 // Dashboard list / transcript view has no caret. Without
2146 // this branch the default cursorRow points at the
2147 // (hidden) main editor row, so the terminal would draw
2148 // a stray block somewhere in the chat region.
2149 cursorRow = -1
2150 cursorCol = 0
2151 }
2152 }
2153 if i.extPanel.Active() {
2154 cursorRow = -1
2155 cursorCol = 0
2156 }
2157 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2158 if rightBarActive {
2159 rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2160 if modalBackdrop {
2161 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2162 } else {
2163 i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2164 }
2165 } else {
2166 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2167 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2168 }
2169 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2170 alert := *i.pendingAlert
2171 i.pendingAlert = nil
2172 i.emitAlertLocked(alert)
2173 }
2174}
2175
2176func hasImageEscape(line string) bool {
2177 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
2178}
2179
2180// snapViewportStartToImageBlock treats inline images as atomic blocks for
2181// scrolling. Terminal image protocols draw from a single escape row into a
2182// separate graphics layer; the following blank rows are only zut's reserved
2183// footprint. If the viewport starts on one of those blank rows, there is no
2184// correct partial-image state to render. Snap back to the escape row instead
2185// so the image is either shown from its beginning or skipped entirely.
2186func snapViewportStartToImageBlock(chat []string, start int) int {
2187 if start <= 0 || start >= len(chat) {
2188 return start
2189 }
2190 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
2191 return start
2192 }
2193 for k := start - 1; k >= 0; k-- {
2194 line := chat[k]
2195 if hasImageEscape(line) {
2196 return k
2197 }
2198 if !isBoxBlankLine(line) {
2199 break
2200 }
2201 }
2202 return start
2203}
2204
2205const (
2206 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
2207 autoCompactContinueMetaKey = "auto_compact_continue"
2208 shellEscapeMetaKey = "shell_escape"
2209)
2210
2211func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
2212 if len(msgs) == 0 {
2213 return nil
2214 }
2215 out := make([]provider.Message, 0, len(msgs))
2216 for _, m := range msgs {
2217 if isHiddenTranscriptMessage(m) {
2218 continue
2219 }
2220 out = append(out, m)
2221 }
2222 return out
2223}
2224
2225func isHiddenTranscriptMessage(m provider.Message) bool {
2226 if m.Meta[autoCompactContinueMetaKey] == "true" {
2227 return true
2228 }
2229 if m.Role != provider.RoleUser || len(m.Content) == 0 {
2230 return false
2231 }
2232 tb, ok := m.Content[0].(provider.TextBlock)
2233 if !ok {
2234 return false
2235 }
2236 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
2237}
2238
2239func clipBottomClippedImages(lines []string) []string {
2240 if len(lines) == 0 {
2241 return lines
2242 }
2243 out := append([]string(nil), lines...)
2244 for i, line := range out {
2245 if !hasImageEscape(line) {
2246 continue
2247 }
2248 // Image blocks render as: image escape, zero or more blank
2249 // reservation rows, then the muted "image - ..." info line,
2250 // then one trailing blank. If the info line isn't visible in
2251 // the current chat slice, the image would paint down into the
2252 // fixed status bar area. Suppress that image for this frame.
2253 //
2254 // When the image lives inside a tool box, the reservation rows
2255 // are wrapped in vertical box edges ("│ ... │"); those rows
2256 // look non-blank under a naive whitespace check but are still
2257 // reservation rows for this scan, so treat them as blank.
2258 foundInfo := false
2259 for j := i + 1; j < len(out); j++ {
2260 if strings.Contains(out[j], "image - ") {
2261 foundInfo = true
2262 break
2263 }
2264 if !isBoxBlankLine(out[j]) {
2265 break
2266 }
2267 }
2268 if !foundInfo {
2269 out[i] = ""
2270 }
2271 }
2272 return out
2273}
2274
2275// isBoxBlankLine reports whether line is visually empty after
2276// stripping ANSI escape sequences, surrounding whitespace, and the
2277// vertical box edges drawn by the tool-box renderer. Used by
2278// clipBottomClippedImages so an image's reservation rows still count
2279// as blank when those rows are wrapped in "│ ... │" inside a tool box.
2280func isBoxBlankLine(line string) bool {
2281 stripped := stripANSIBytes(line)
2282 stripped = strings.TrimSpace(stripped)
2283 stripped = strings.Trim(stripped, "│")
2284 stripped = strings.TrimSpace(stripped)
2285 return stripped == ""
2286}
2287
2288// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
2289// byte) from s without pulling in the regexp package. Mirrors the
2290// internal helper in package tui; the duplicated copy avoids exporting
2291// it just for one caller.
2292func stripANSIBytes(s string) string {
2293 if !strings.Contains(s, "\x1b") {
2294 return s
2295 }
2296 var b strings.Builder
2297 b.Grow(len(s))
2298 i := 0
2299 for i < len(s) {
2300 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
2301 end := i + 2
2302 for end < len(s) {
2303 c := s[end]
2304 end++
2305 if c >= 0x40 && c <= 0x7e {
2306 break
2307 }
2308 }
2309 i = end
2310 continue
2311 }
2312 b.WriteByte(s[i])
2313 i++
2314 }
2315 return b.String()
2316}
2317
2318// truncateLine shortens s so it fits within n display cells, with an
2319// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
2320// novel doesn't blow past the status line.
2321func panelKeyName(k tui.Key) string {
2322 switch k.Kind {
2323 case tui.KeyUp:
2324 return "up"
2325 case tui.KeyDown:
2326 return "down"
2327 case tui.KeyLeft:
2328 return "left"
2329 case tui.KeyRight:
2330 return "right"
2331 case tui.KeyEnter:
2332 return "enter"
2333 case tui.KeyEsc:
2334 return "esc"
2335 case tui.KeyTab:
2336 return "tab"
2337 case tui.KeyBackspace:
2338 return "backspace"
2339 case tui.KeyDelete:
2340 return "delete"
2341 case tui.KeyHome:
2342 return "home"
2343 case tui.KeyEnd:
2344 return "end"
2345 case tui.KeyPageUp:
2346 return "pageup"
2347 case tui.KeyPageDown:
2348 return "pagedown"
2349 case tui.KeyRune:
2350 return "rune"
2351 default:
2352 return "unknown"
2353 }
2354}
2355
2356func panelKeyText(k tui.Key) string {
2357 if k.Kind == tui.KeyRune {
2358 return string(k.Rune)
2359 }
2360 return ""
2361}
2362
2363func truncateLine(s string, n int) string {
2364 if n <= 0 {
2365 return ""
2366 }
2367 // Collapse newlines — chips are single line.
2368 s = strings.ReplaceAll(s, "\n", " ↩ ")
2369 runes := []rune(s)
2370 if len(runes) <= n {
2371 return s
2372 }
2373 if n <= 3 {
2374 return strings.Repeat(".", n)
2375 }
2376 return string(runes[:n-3]) + "..."
2377}
2378
2379// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2380// will exit instead of just clearing input. Long enough to be
2381// deliberate (rules out accidental key chord), short enough that the
2382// hint stays meaningful.
2383const ctrlCExitWindow = 2 * time.Second
2384
2385// armCtrlCExit records the timestamp of the current ctrl+c so the next
2386// one within ctrlCExitWindow exits.
2387func (i *Interactive) armCtrlCExit() {
2388 i.mu.Lock()
2389 i.lastCtrlC = time.Now()
2390 i.mu.Unlock()
2391}
2392
2393// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2394// that another press should now exit.
2395func (i *Interactive) ctrlCExitArmed() bool {
2396 i.mu.Lock()
2397 t := i.lastCtrlC
2398 i.mu.Unlock()
2399 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2400}
2401
2402// clearFileSuggestQuery strips the filter the user typed after the
2403// last "@", leaving the bare "@" so the picker stays open. Called when
2404// navigating between directory levels (Right/Left): the filter applied
2405// to the level the user was on, not the one being entered, so carrying
2406// it forward would wrongly hide the new directory's contents.
2407func (i *Interactive) clearFileSuggestQuery() {
2408 val := i.ed.Value()
2409 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2410 i.ed.SetValue(val[:idx+1])
2411 }
2412}
2413
2414// setToolExpansion updates long tool result expansion and replays the
2415// transcript so already-emitted rows reflect the new state.
2416func (i *Interactive) setToolExpansion(expanded bool) {
2417 i.mu.Lock()
2418 i.view.ExpandAll = expanded
2419 if i.rend != nil {
2420 i.rend.Clear()
2421 }
2422 i.mu.Unlock()
2423 i.invalidate()
2424}
2425
2426func (i *Interactive) toggleToolExpansion() {
2427 i.mu.Lock()
2428 expanded := !i.view.ExpandAll
2429 i.mu.Unlock()
2430 i.setToolExpansion(expanded)
2431}
2432
2433func (i *Interactive) toggleBtwToolExpansion() {
2434 i.btwDialog.ToggleToolExpansion()
2435 i.mu.Lock()
2436 if i.rend != nil {
2437 i.rend.Clear()
2438 }
2439 i.mu.Unlock()
2440 i.invalidate()
2441}
2442
2443// confirmChildActive reports whether an interaction opened from slash input
2444// currently owns the keyboard while a tool confirmation remains pending.
2445func (i *Interactive) confirmChildActive() bool {
2446 i.mu.Lock()
2447 defer i.mu.Unlock()
2448 return i.confirmChildActiveLocked()
2449}
2450
2451func (i *Interactive) confirmChildActiveLocked() bool {
2452 return len(i.helpBlock) > 0 ||
2453 len(i.extNotes) > 0 ||
2454 i.dialog.Active() ||
2455 i.modelDialog.Active() ||
2456 i.llamaDialog.Active() ||
2457 i.rescueDialog.Active() ||
2458 i.sessionDialog.Active() ||
2459 i.subagentsDialog.Active() ||
2460 i.jumpDialog.Active() ||
2461 i.btwDialog.Active() ||
2462 i.skillsDialog.Active() ||
2463 i.changelogDialog.Active() ||
2464 i.logoutDialog.Active() ||
2465 i.telegramDialog.Active() ||
2466 i.settingsDialog.Active() ||
2467 i.sessionOpsDialog.Active() ||
2468 i.sessionTreeDialog.Active() ||
2469 i.extPanel.Active()
2470}
2471
2472// restoreConfirmFocus returns input to confirmation after slash input is
2473// cleared or a child interaction closes. A non-empty editor keeps command
2474// input focused, and an active child continues to own its keys.
2475func (i *Interactive) restoreConfirmFocus() {
2476 if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2477 return
2478 }
2479 if !i.ed.IsEmpty() || i.confirmChildActive() {
2480 return
2481 }
2482 i.confirmDialog.Focus()
2483 i.invalidate()
2484}
2485
2486func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2487 defer i.restoreConfirmFocus()
2488
2489 // A bare Escape is eligible for the session-tree gesture only while the
2490 // main input owns the key. Any other key, modified Escape, or visible
2491 // child interaction resets the pending first tap before normal routing.
2492 if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
2493 i.mu.Lock()
2494 i.doubleEscape.Reset()
2495 i.mu.Unlock()
2496 }
2497
2498 // Dialogs route keys before the main clipboard handler below. Resolve text
2499 // here when a child interaction owns input so every editor and filter sees
2500 // the same KeyPaste event as terminal-native bracketed paste. Main chat is
2501 // left image-first to preserve macOS clipboard image attachments.
2502 if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
2503 resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
2504 if err != nil {
2505 i.mu.Lock()
2506 i.statusErr = "clipboard paste failed: " + err.Error()
2507 i.statusOK = ""
2508 i.mu.Unlock()
2509 return false
2510 }
2511 if pastedText {
2512 k = resolved
2513 i.mu.Lock()
2514 i.statusErr = ""
2515 i.statusOK = ""
2516 i.mu.Unlock()
2517 }
2518 }
2519
2520 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2521 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
2522 // unexpectedly. The hint message also goes stale; clear it.
2523 if k.Kind != tui.KeyCtrlC {
2524 i.mu.Lock()
2525 if !i.lastCtrlC.IsZero() {
2526 i.lastCtrlC = time.Time{}
2527 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
2528 i.statusOK = ""
2529 }
2530 }
2531 i.mu.Unlock()
2532 }
2533
2534 // Dialogs consume keys while open (except ctrl+c, which always closes them).
2535
2536 // Confirmation owns input by default. Pressing / deliberately moves
2537 // focus to the main editor without answering the pending request, so
2538 // slash commands and the dialogs they open can run first.
2539 if i.confirmDialog.Focused() {
2540 if k.Kind == tui.KeyRune && k.Rune == '/' {
2541 i.confirmDialog.Blur()
2542 i.ed.Insert("/")
2543 i.suggest.Reset()
2544 i.invalidate()
2545 return false
2546 }
2547 if k.Kind == tui.KeyCtrlO {
2548 if i.btwDialog != nil && i.btwDialog.Active() {
2549 i.toggleBtwToolExpansion()
2550 } else {
2551 i.toggleToolExpansion()
2552 }
2553 return false
2554 }
2555 i.confirmDialog.HandleKey(k)
2556 i.invalidate()
2557 return false
2558 }
2559 if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2560 i.ed.Clear()
2561 i.suggest.Reset()
2562 i.fileSuggest.Reset()
2563 i.confirmDialog.Focus()
2564 i.invalidate()
2565 return false
2566 }
2567 if i.dialog.Active() {
2568 if k.Kind == tui.KeyCtrlC {
2569 i.dialog.Close()
2570 if i.cfg.AuthManager != nil {
2571 i.cfg.AuthManager.CancelOAuth()
2572 }
2573 return false
2574 }
2575 act := i.dialog.HandleKey(k)
2576 if act.StartAPIKey {
2577 i.startAPIKeyFlow(act.Provider)
2578 }
2579 if act.StartOAuth {
2580 i.startOAuthFlow(act.Provider)
2581 }
2582 if act.StartManual {
2583 i.startManualOAuthFlow(act.Provider)
2584 }
2585 if act.SubmitCode != "" {
2586 i.submitManualOAuthCode(act.SubmitCode)
2587 }
2588 if act.SaveLlama {
2589 i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2590 }
2591 return false
2592 }
2593 if i.modelDialog.Active() {
2594 if k.Kind == tui.KeyCtrlC {
2595 i.modelDialog.Close()
2596 i.quickModelAssign = 0
2597 return false
2598 }
2599 act := i.modelDialog.HandleKey(k)
2600 if act.Close {
2601 i.quickModelAssign = 0
2602 }
2603 if act.Select {
2604 if i.quickModelAssign > 0 {
2605 i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2606 i.quickModelAssign = 0
2607 } else {
2608 i.applyModelSelection(act.Provider, act.Model)
2609 }
2610 }
2611 return false
2612 }
2613 if i.llamaDialog.Active() {
2614 i.llamaDialog.HandleKey(k)
2615 i.invalidate()
2616 return false
2617 }
2618 if i.rescueDialog.Active() {
2619 if k.Kind == tui.KeyCtrlC {
2620 i.rescueDialog.Close()
2621 i.invalidate()
2622 return false
2623 }
2624 act := i.rescueDialog.HandleKey(k)
2625 if act.Select {
2626 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2627 }
2628 i.invalidate()
2629 return false
2630 }
2631 i.mu.Lock()
2632 sessionDialogActive := i.sessionDialog.Active()
2633 i.mu.Unlock()
2634 if sessionDialogActive {
2635 if k.Kind == tui.KeyCtrlC {
2636 i.mu.Lock()
2637 i.sessionDialog.Close()
2638 i.sessionLoads = nil
2639 i.mu.Unlock()
2640 i.invalidate()
2641 return false
2642 }
2643 manualRenamePath := ""
2644 i.mu.Lock()
2645 if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
2646 manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2647 }
2648 i.mu.Unlock()
2649 manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
2650 if manualRenameCurrent {
2651 i.markSessionTitleSwitching()
2652 }
2653 i.mu.Lock()
2654 act := i.sessionDialog.HandleKey(k)
2655 if act.Select || act.Close {
2656 i.sessionLoads = nil
2657 }
2658 i.mu.Unlock()
2659 if act.Select {
2660 i.applySessionSelection(act.Path)
2661 }
2662 if act.Err != nil {
2663 if manualRenameCurrent {
2664 i.restoreFailedSessionTitle()
2665 }
2666 i.mu.Lock()
2667 i.statusErr = "rename: " + act.Err.Error()
2668 i.statusOK = ""
2669 i.mu.Unlock()
2670 } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
2671 i.setManualSessionTitle(act.RenameTitle)
2672 }
2673 // Always request a redraw after handling a key here: when esc
2674 // closes the picker, the overlay-close detection in the render
2675 // pass needs to run so the tall dialog rows get repainted from
2676 // the chat (otherwise VS Code's retained scrollback leaves a
2677 // duplicate frame on screen).
2678 i.invalidate()
2679 return false
2680 }
2681 if i.subagentsDialog.Active() {
2682 if k.Kind == tui.KeyCtrlC {
2683 i.subagentsDialog.Close()
2684 i.invalidate()
2685 return false
2686 }
2687 _, msg, errMsg := i.subagentsDialog.HandleKey(k)
2688 if msg != "" || errMsg != "" {
2689 i.mu.Lock()
2690 i.statusOK = msg
2691 i.statusErr = errMsg
2692 i.mu.Unlock()
2693 }
2694 i.invalidate()
2695 return false
2696 }
2697 if i.logoutDialog.Active() {
2698 if k.Kind == tui.KeyCtrlC {
2699 i.logoutDialog.Close()
2700 i.invalidate()
2701 return false
2702 }
2703 act := i.logoutDialog.HandleKey(k)
2704 if act.Select {
2705 i.doLogout(act.Target)
2706 }
2707 i.invalidate()
2708 return false
2709 }
2710 if i.telegramDialog.Active() {
2711 if k.Kind == tui.KeyCtrlC {
2712 i.telegramDialog.Close()
2713 i.invalidate()
2714 return false
2715 }
2716 act := i.telegramDialog.HandleKey(k)
2717 if act.Select {
2718 i.doTelegram(act.Action)
2719 }
2720 i.invalidate()
2721 return false
2722 }
2723 if i.settingsDialog.Active() {
2724 if k.Kind == tui.KeyCtrlC {
2725 i.settingsDialog.Close()
2726 i.invalidate()
2727 return false
2728 }
2729 act := i.settingsDialog.HandleKey(k)
2730 if act.ModelShortcutSlot > 0 {
2731 i.openQuickModelPicker(act.ModelShortcutSlot)
2732 }
2733 if act.Toggle {
2734 i.applySettingChange(act)
2735 }
2736 i.invalidate()
2737 return false
2738 }
2739 if i.sessionOpsDialog.Active() {
2740 if k.Kind == tui.KeyCtrlC {
2741 i.sessionOpsDialog.Close()
2742 i.invalidate()
2743 return false
2744 }
2745 act := i.sessionOpsDialog.HandleKey(k)
2746 if act.Select {
2747 i.doSessionOp(act.Action, "")
2748 }
2749 i.invalidate()
2750 return false
2751 }
2752 if i.sessionTreeDialog.Active() {
2753 if k.Kind == tui.KeyCtrlC {
2754 i.sessionTreeDialog.Close()
2755 i.invalidate()
2756 return false
2757 }
2758 act := i.sessionTreeDialog.HandleKey(k)
2759 if act.Select {
2760 if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
2761 i.applySessionTreeTarget(act.Target, act.TurnNo)
2762 } else {
2763 // Keep package embedders that still return the scalar action
2764 // source-compatible while the dialog migrates to Target.
2765 i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2766 }
2767 }
2768 i.invalidate()
2769 return false
2770 }
2771 if i.extPanel.Active() {
2772 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2773 if i.cfg.Extensions != nil {
2774 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2775 }
2776 i.extPanel.Close()
2777 i.invalidate()
2778 return false
2779 }
2780 if i.cfg.Extensions != nil {
2781 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2782 }
2783 return false
2784 }
2785 if i.jumpDialog.Active() {
2786 if k.Kind == tui.KeyCtrlC {
2787 i.jumpDialog.Close()
2788 i.pendingFork = false
2789 return false
2790 }
2791 act := i.jumpDialog.HandleKey(k)
2792 if act.Select {
2793 if i.pendingFork {
2794 i.applyForkSelection(act.MessageIdx)
2795 } else {
2796 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2797 }
2798 }
2799 // If the user dismissed the dialog without selecting, also
2800 // clear the pending-fork flag so a later plain /jump isn't
2801 // hijacked.
2802 if act.Close {
2803 i.pendingFork = false
2804 }
2805 return false
2806 }
2807 if i.btwDialog.Active() {
2808 if k.Kind == tui.KeyCtrlC {
2809 i.btwDialog.Close()
2810 i.invalidate()
2811 return false
2812 }
2813 if k.Kind == tui.KeyCtrlO {
2814 i.toggleBtwToolExpansion()
2815 return false
2816 }
2817 i.btwDialog.HandleKey(k, i.invalidate)
2818 return false
2819 }
2820 if i.skillsDialog.Active() {
2821 if k.Kind == tui.KeyCtrlC {
2822 i.skillsDialog.Close()
2823 i.invalidate()
2824 return false
2825 }
2826 i.skillsDialog.HandleKey(k)
2827 i.invalidate()
2828 return false
2829 }
2830 if i.changelogDialog.Active() {
2831 if closed := i.changelogDialog.HandleKey(k); closed {
2832 // User dismissed; let the parent persist the
2833 // LastChangelogShown marker via the close callback.
2834 if i.cfg.OnChangelogDismiss != nil {
2835 i.cfg.OnChangelogDismiss()
2836 }
2837 }
2838 i.invalidate()
2839 return false
2840 }
2841
2842 if slot := quickModelShortcutSlot(k); slot > 0 {
2843 i.applyQuickModelShortcut(slot)
2844 return false
2845 }
2846
2847 // The first eligible bare Escape is deliberately left to the existing
2848 // Escape path (which clears idle input or cancels a turn). A second tap
2849 // within the gesture window only gets first refusal while the cheap,
2850 // local eligibility rules still hold. If a turn started, a message was
2851 // queued, or the input became ineligible between taps, reset the detector
2852 // and fall through so Escape retains its normal cancellation behavior.
2853 // Once those rules still hold, the full tree gate owns the second tap:
2854 // family/read failures are fail-closed but are nevertheless consumed so
2855 // they cannot fall through into ordinary editor handling.
2856 if isUnmodifiedEscape(k) {
2857 now := i.sessionTreeEscapeNow()
2858 i.mu.Lock()
2859 consumed := i.doubleEscape.Consume(now)
2860 i.mu.Unlock()
2861 if consumed {
2862 if i.canArmSessionTreeEscape() {
2863 i.openSessionTree()
2864 return false
2865 }
2866 i.mu.Lock()
2867 i.doubleEscape.Reset()
2868 i.mu.Unlock()
2869 } else if i.canArmSessionTreeEscape() {
2870 i.mu.Lock()
2871 i.doubleEscape.Arm(now)
2872 i.mu.Unlock()
2873 }
2874 }
2875
2876 // Global keys.
2877 switch k.Kind {
2878 case tui.KeyCtrlC:
2879 i.mu.Lock()
2880 loadingSession := i.sessionLoading
2881 i.mu.Unlock()
2882 if loadingSession {
2883 return true
2884 }
2885 // While busy: do NOT cancel the turn. ctrl+c during a
2886 // running turn is almost always reflex muscle memory
2887 // ("be quiet" in a shell) rather than a deliberate
2888 // decision to kill a multi-minute model call that's
2889 // already cost tokens. Use esc to interrupt a turn; use
2890 // a deliberate double-ctrl+c to exit zut entirely. First
2891 // press arms the exit hint, second press within
2892 // ctrlCExitWindow quits.
2893 if i.busy {
2894 if i.ctrlCExitArmed() {
2895 return true
2896 }
2897 i.mu.Lock()
2898 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2899 i.statusErr = ""
2900 i.mu.Unlock()
2901 i.armCtrlCExit()
2902 return false
2903 }
2904 // Idle: first press clears the editor (and any queued
2905 // follow-up messages); a second press within ctrlCExitWindow
2906 // exits. With both an empty editor and no queue the first
2907 // press still just arms — require a deliberate double-tap.
2908 ag := i.agent
2909 pending := 0
2910 if ag != nil {
2911 pending = ag.QueuedMessageCount()
2912 }
2913 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2914 if hadInput {
2915 i.ed.Clear()
2916 i.clipboardImages = nil
2917 i.suggest.Reset()
2918 if ag != nil {
2919 ag.DrainQueuedMessages()
2920 }
2921 i.mu.Lock()
2922 i.queued = nil
2923 i.statusOK = "input cleared"
2924 i.statusErr = ""
2925 i.mu.Unlock()
2926 i.armCtrlCExit()
2927 return false
2928 }
2929 if i.ctrlCExitArmed() {
2930 return true
2931 }
2932 i.mu.Lock()
2933 i.statusOK = "press ctrl+c again to exit"
2934 i.statusErr = ""
2935 i.mu.Unlock()
2936 i.armCtrlCExit()
2937 return false
2938 case tui.KeyEsc:
2939 // Esc interrupts a running turn — but only when nothing
2940 // else on screen wants to consume the key first. The slash
2941 // popup has its own Esc behaviour (close + clear editor),
2942 // and transient overlays like the /help block and extension
2943 // notes should dismiss on Esc before we even consider the
2944 // turn. Without these guards, a casual Esc press after
2945 // running /help on a busy turn rips the turn away.
2946 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2947 break
2948 }
2949 i.mu.Lock()
2950 hadHelp := len(i.helpBlock) > 0
2951 hadNotes := len(i.extNotes) > 0
2952 if hadHelp {
2953 i.helpBlock = nil
2954 }
2955 if hadNotes {
2956 i.extNotes = nil
2957 }
2958 i.mu.Unlock()
2959 if hadHelp || hadNotes {
2960 i.invalidate()
2961 return false
2962 }
2963 if i.busy && i.cancelTurn != nil {
2964 i.cancelTurn()
2965 // If a confirm dialog is pending, refuse it so the agent
2966 // goroutine unblocks and the context cancellation can
2967 // actually take effect.
2968 i.confirmDialog.CancelAll("turn cancelled")
2969 return false
2970 }
2971 case tui.KeyCtrlD:
2972 if i.ed.IsEmpty() && !i.busy {
2973 return true
2974 }
2975 case tui.KeyCtrlB:
2976 i.mu.Lock()
2977 i.rightBarHidden = !i.rightBarHidden
2978 i.mu.Unlock()
2979 i.rend.Invalidate()
2980 i.invalidate()
2981 return false
2982 case tui.KeyCtrlL:
2983 i.rend.Clear()
2984 i.invalidate()
2985 return false
2986 case tui.KeyPasteClipboard:
2987 i.pasteClipboard(ctx)
2988 return false
2989 case tui.KeyCtrlO:
2990 i.toggleToolExpansion()
2991 return false
2992 case tui.KeyPageUp:
2993 i.scrollBy(+i.chatPage())
2994 return false
2995 case tui.KeyPageDown:
2996 i.scrollBy(-i.chatPage())
2997 return false
2998 case tui.KeyUp:
2999 // Alt/Option+Up: pop the most recently queued ("sliding in")
3000 // message back into the editor so the user can edit and
3001 // resend it. Repeated presses keep peeling messages off the
3002 // tail of the queue; each press *replaces* the editor
3003 // contents (we don't append/push). When the queue is empty
3004 // the keypress falls through to the normal scroll behavior.
3005 if k.Alt {
3006 i.mu.Lock()
3007 var text string
3008 if i.agent != nil {
3009 text, _ = i.agent.PopQueuedMessage()
3010 }
3011 if text == "" {
3012 if n := len(i.queued); n > 0 {
3013 text = i.queued[n-1]
3014 i.queued = i.queued[:n-1]
3015 }
3016 }
3017 i.mu.Unlock()
3018 if text != "" {
3019 i.ed.SetValue(text)
3020 i.inputHistoryIndex = -1
3021 i.invalidate()
3022 return false
3023 }
3024 }
3025 // In multi-line / wrapped input, Up first moves inside the editor.
3026 // At the editor's top edge, plain Up can browse input history when
3027 // history browsing is safe/active; otherwise it falls back to chat
3028 // scrolling, preserving the old single-line scroll behavior.
3029 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3030 if i.ed.MoveVertical(-1) {
3031 i.invalidate()
3032 return false
3033 }
3034 if !k.Alt && i.handleInputHistoryKey(k) {
3035 return false
3036 }
3037 i.scrollBy(+3)
3038 return false
3039 }
3040 case tui.KeyDown:
3041 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3042 if i.ed.MoveVertical(+1) {
3043 i.invalidate()
3044 return false
3045 }
3046 if !k.Alt && i.handleInputHistoryKey(k) {
3047 return false
3048 }
3049 if i.scrollOffset > 0 {
3050 i.scrollBy(-3)
3051 }
3052 return false
3053 }
3054 }
3055
3056 // Note: we intentionally do NOT gate the editor on i.busy here.
3057 // Typing while the agent is working is supported — submitted
3058 // messages are queued and delivered as follow-up turns when the
3059 // current turn ends. See the submit handler below.
3060
3061 if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
3062 i.ed.HandleKey(k)
3063 return false
3064 }
3065
3066 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
3067 if i.suggest.Active(i.ed.Value()) {
3068 switch k.Kind {
3069 case tui.KeyUp:
3070 i.suggest.Up()
3071 return false
3072 case tui.KeyDown:
3073 i.suggest.Down()
3074 return false
3075 case tui.KeyPageUp:
3076 i.suggest.PageUp()
3077 return false
3078 case tui.KeyPageDown:
3079 i.suggest.PageDown()
3080 return false
3081 case tui.KeyTab:
3082 if name := i.suggest.Selection(i.ed.Value()); name != "" {
3083 i.ed.SetValue(name)
3084 i.suggest.Reset()
3085 }
3086 return false
3087 case tui.KeyEnter:
3088 // Enter on an ambiguous or partial slash prefix: complete to the
3089 // currently highlighted command and run it. That way typing
3090 // "/lo" + enter picks whichever of /login or /logout is selected
3091 // in the popup instead of submitting "/lo" as unknown. Also
3092 // clear the editor so the command doesn't linger after the
3093 // dialog opens/closes.
3094 if name := i.suggest.Selection(i.ed.Value()); name != "" {
3095 i.ed.Clear()
3096 i.suggest.Reset()
3097 return i.runSlash(ctx, name)
3098 }
3099 case tui.KeyEsc:
3100 i.ed.Clear()
3101 i.suggest.Reset()
3102 return false
3103 }
3104 }
3105
3106 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
3107 if i.fileSuggest.Active(i.ed.Value()) {
3108 switch k.Kind {
3109 case tui.KeyUp:
3110 i.fileSuggest.Up()
3111 return false
3112 case tui.KeyDown:
3113 i.fileSuggest.Down()
3114 return false
3115 case tui.KeyRight:
3116 // Open selected directory. The filter the user typed picked
3117 // that directory at the current level; once we descend it no
3118 // longer applies to the directory's contents, so clear it.
3119 // Otherwise typing "@eda" then right would re-filter inside
3120 // eda/ by "eda" and show nothing.
3121 if i.fileSuggest.Right() {
3122 i.clearFileSuggestQuery()
3123 }
3124 return false
3125 case tui.KeyLeft:
3126 // Go back to parent directory. Clear the filter for the same
3127 // reason as Right: it was scoped to the level we just left.
3128 if i.fileSuggest.Left() {
3129 i.clearFileSuggestQuery()
3130 }
3131 return false
3132 case tui.KeyEnter:
3133 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
3134 var chip string
3135 if entry.isDir {
3136 chip = "[dir:" + entry.rel + "/]"
3137 } else {
3138 chip = "[file:" + entry.rel + "]"
3139 }
3140 val := i.ed.Value()
3141 if idx := strings.LastIndex(val, "@"); idx >= 0 {
3142 val = val[:idx]
3143 }
3144 i.ed.SetValue(val + chip + " ")
3145 i.fileSuggest.Reset()
3146 }
3147 return false
3148 case tui.KeyEsc:
3149 val := i.ed.Value()
3150 if idx := strings.LastIndex(val, "@"); idx >= 0 {
3151 i.ed.SetValue(val[:idx])
3152 }
3153 i.fileSuggest.Reset()
3154 return false
3155 }
3156 }
3157
3158 // Tab-complete a path token in the editor when no popup is open.
3159 // Recognises tokens that look like paths (start with ~, /, ./, ../
3160 // or contain a slash); shell-style completion expands ~, lists the
3161 // parent dir, and completes the basename to the longest common
3162 // prefix. Single match: full replace and trailing / for dirs.
3163 // No match: no-op. Plain bare words (no slash, no tilde) fall
3164 // through so Tab keeps its current no-op behaviour outside paths.
3165 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3166 if i.tryPathTabComplete() {
3167 return false
3168 }
3169 }
3170
3171 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3172 i.inputHistoryIndex = -1
3173 }
3174
3175 if k.Kind == tui.KeyEsc {
3176 i.clipboardImages = nil
3177 }
3178 if submit := i.ed.HandleKey(k); submit {
3179 // SubmitValue() expands any [pasted text #N +L lines]
3180 // placeholders back into their bodies; the raw Value()
3181 // is only what the user sees on screen.
3182 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
3183 // Expand [file:name] and [dir:name/] chips to full paths.
3184 text = expandFileChips(text, i.cfg.CWD)
3185 text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
3186 if text == "" && len(images) == 0 {
3187 return false
3188 }
3189 clearSubmittedInput := func() {
3190 i.clipboardImages = nil
3191 i.ed.Clear()
3192 i.inputHistoryIndex = -1
3193 i.suggest.Reset()
3194 i.fileSuggest.Reset()
3195 }
3196
3197 // Shell escapes and slash commands are text-only. An image-bearing
3198 // draft remains a normal provider prompt instead of silently dropping
3199 // its attachments.
3200 if len(images) == 0 {
3201 if cmd, ok := shellEscapeCommand(text); ok {
3202 clearSubmittedInput()
3203 i.startShellEscape(ctx, cmd)
3204 return false
3205 }
3206 }
3207
3208 if len(images) == 0 && looksLikeSlashCommand(text) {
3209 clearSubmittedInput()
3210 head := text
3211 rest := ""
3212 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3213 head = text[:idx]
3214 rest = strings.TrimSpace(text[idx:])
3215 }
3216 if !isKnownSlashCommand(text) {
3217 // Try extensions before giving up. Extensions register
3218 // commands by bare name (no leading slash); strip it here.
3219 extName := strings.TrimPrefix(head, "/")
3220 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
3221 go i.invokeExtensionCommand(ctx, extName, rest)
3222 return false
3223 }
3224 i.mu.Lock()
3225 i.statusErr = "unknown command " + head + " — type /help to see the list"
3226 i.statusOK = ""
3227 i.mu.Unlock()
3228 return false
3229 }
3230 // Slash commands run regardless of busy state. Commands that
3231 // would mutate the transcript or replace the agent (/clear,
3232 // /compact, /logout, /login, /model, /fork, /session fork)
3233 // cancel the active turn
3234 // first and wait for the goroutine to wind down so they don't
3235 // race with a streaming response. Safe commands (/help,
3236 // /jump, /sessions, /jail, /unjail, /exit) run immediately
3237 // without disturbing the active turn.
3238 if slashCommandCancelsTurn(text) {
3239 i.cancelAndWaitForIdle()
3240 }
3241 return i.runSlash(ctx, text)
3242 }
3243
3244 if i.agent == nil {
3245 i.mu.Lock()
3246 i.statusErr = "not logged in. type /login first."
3247 i.mu.Unlock()
3248 return false
3249 }
3250 // Mirror the user's typed prompt into the paired Telegram
3251 // chat (when the bridge is active) so the Telegram thread
3252 // stays a complete record of the session, not just the half
3253 // that originated on the phone. On a goroutine so the
3254 // network write doesn't delay the local turn.
3255 if i.telegramBridge != nil && i.telegramBridge.Active() {
3256 go i.telegramBridge.OnUserTyped(text)
3257 }
3258 i.maybeStartSessionTitle(ctx, text)
3259 // If a turn is already in flight, queue this prompt inside the
3260 // agent loop so it is delivered at the next safe model-call
3261 // boundary instead of waiting for the whole run to finish.
3262 i.mu.Lock()
3263 busy := i.busy
3264 compacting := i.compacting
3265 ag := i.agent
3266 i.mu.Unlock()
3267 if busy {
3268 if len(images) > 0 {
3269 i.mu.Lock()
3270 i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
3271 i.mu.Unlock()
3272 i.invalidate()
3273 return false
3274 }
3275 clearSubmittedInput()
3276 if ag != nil && !compacting {
3277 ag.QueueMessage(text)
3278 } else {
3279 i.mu.Lock()
3280 i.queued = append(i.queued, text)
3281 i.mu.Unlock()
3282 }
3283 i.invalidate()
3284 return false
3285 }
3286 clearSubmittedInput()
3287 i.startTurnWithImages(ctx, text, images)
3288 }
3289 return false
3290}
3291
3292func (i *Interactive) pasteClipboard(ctx context.Context) {
3293 image, ok, _ := tui.ReadClipboardImage(ctx)
3294 if ok {
3295 i.mu.Lock()
3296 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
3297 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
3298 Marker: marker,
3299 Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
3300 })
3301 i.ed.Insert(marker + " ")
3302 i.statusOK = ""
3303 i.statusErr = ""
3304 i.mu.Unlock()
3305 return
3306 }
3307 key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
3308 i.mu.Lock()
3309 defer i.mu.Unlock()
3310 if err != nil {
3311 i.statusErr = "clipboard paste failed: " + err.Error()
3312 i.statusOK = ""
3313 return
3314 }
3315 if pastedText {
3316 i.ed.HandleKey(key)
3317 i.statusErr = ""
3318 i.statusOK = ""
3319 return
3320 }
3321 i.statusErr = "clipboard does not contain text or an image"
3322 i.statusOK = ""
3323}
3324
3325func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
3326 if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3327 return false
3328 }
3329 // Do not steal normal vertical cursor movement. History browsing can only
3330 // start from an empty editor; once active, Up/Down keep walking
3331 // the ring so repeated presses work even though the editor now
3332 // contains the selected historical prompt.
3333 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
3334 return false
3335 }
3336 hist := i.inputHistory()
3337 if len(hist) == 0 {
3338 return false
3339 }
3340
3341 if i.inputHistoryIndex < 0 {
3342 // Start just after the newest item so Up lands on the most
3343 // recent user prompt. A lone Down from an empty editor is not
3344 // history navigation; let the caller fall through to normal UI
3345 // behavior instead.
3346 if k.Kind != tui.KeyUp {
3347 return false
3348 }
3349 i.inputHistoryIndex = len(hist)
3350 }
3351
3352 switch k.Kind {
3353 case tui.KeyUp:
3354 if i.inputHistoryIndex > 0 {
3355 i.inputHistoryIndex--
3356 }
3357 case tui.KeyDown:
3358 if i.inputHistoryIndex < len(hist) {
3359 i.inputHistoryIndex++
3360 }
3361 }
3362
3363 if i.inputHistoryIndex >= len(hist) {
3364 i.ed.Clear()
3365 } else {
3366 i.ed.SetValue(hist[i.inputHistoryIndex])
3367 }
3368 return true
3369}
3370
3371func (i *Interactive) inputHistory() []string {
3372 if i.agent == nil {
3373 return nil
3374 }
3375 msgs := i.agent.Messages()
3376 hist := make([]string, 0, len(msgs))
3377 for _, m := range msgs {
3378 if !isForkableUserMessage(m) {
3379 continue
3380 }
3381 text := userMessageText(m)
3382 if strings.TrimSpace(text) == "" {
3383 continue
3384 }
3385 hist = append(hist, text)
3386 }
3387 return hist
3388}
3389
3390func userMessageText(m provider.Message) string {
3391 var sb strings.Builder
3392 for _, c := range m.Content {
3393 if tb, ok := c.(provider.TextBlock); ok {
3394 if sb.Len() > 0 {
3395 sb.WriteByte('\n')
3396 }
3397 sb.WriteString(tb.Text)
3398 }
3399 }
3400 return sb.String()
3401}
3402
3403// invokeExtensionCommand fires an extension-registered slash command
3404// in a background goroutine, awaits the response, and applies the
3405// requested action (prompt / insert / display / noop). Errors and
3406// timeouts surface as a status_err line.
3407
3408func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
3409 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
3410 if err != nil {
3411 i.mu.Lock()
3412 i.statusErr = "extension /" + name + ": " + err.Error()
3413 i.statusOK = ""
3414 i.mu.Unlock()
3415 i.invalidate()
3416 return
3417 }
3418 if resp.Error != "" {
3419 i.mu.Lock()
3420 i.statusErr = "extension /" + name + ": " + resp.Error
3421 i.statusOK = ""
3422 i.mu.Unlock()
3423 i.invalidate()
3424 return
3425 }
3426 switch resp.Action {
3427 case "open_panel":
3428 if resp.OpenPanel != nil {
3429 extName := name
3430 if i.cfg.Extensions != nil {
3431 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3432 extName = owner
3433 }
3434 }
3435 i.OpenPanel(extName, *resp.OpenPanel)
3436 }
3437 case "prompt":
3438 if strings.TrimSpace(resp.Prompt) == "" {
3439 return
3440 }
3441 i.startTurn(i.runCtx, resp.Prompt)
3442 case "insert":
3443 i.ed.Insert(resp.Insert)
3444 i.invalidate()
3445 case "display":
3446 i.appendExtensionNote(name, resp.Display, "info")
3447 case "noop", "":
3448 // nothing
3449 default:
3450 i.mu.Lock()
3451 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3452 i.mu.Unlock()
3453 i.invalidate()
3454 }
3455}
3456
3457// appendExtensionNote renders an extension-originated note in the
3458// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3459// "success" (tool/ok green).
3460func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3461 if msg == "" {
3462 return
3463 }
3464 i.mu.Lock()
3465 defer i.mu.Unlock()
3466 color := i.cfg.Theme.Muted
3467 switch level {
3468 case "warn":
3469 color = i.cfg.Theme.Warning
3470 case "error":
3471 color = i.cfg.Theme.Error
3472 case "success":
3473 color = i.cfg.Theme.Tool
3474 }
3475 prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
3476 for _, line := range strings.Split(msg, "\n") {
3477 i.statusOK = "" // clear any stale ok
3478 i.statusErr = ""
3479 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
3480 }
3481 // Extension slash commands complete asynchronously. If their result is
3482 // displayed while confirmation is waiting, keep Esc assigned to the note
3483 // until it is dismissed rather than letting it answer the tool call.
3484 i.confirmDialog.Blur()
3485}
3486
3487// HostHooks implementation for the extension manager. The manager
3488// holds an interface, not a concrete *Interactive, so these methods
3489// are the only thing the manager sees.
3490
3491// Notify is the manager's NotifyFromExt entry point.
3492func (i *Interactive) Notify(extName, level, message string) {
3493 i.appendExtensionNote(extName, message, level)
3494 i.invalidate()
3495}
3496
3497// Alert is the manager's AlertFromExt entry point. Alerts are emitted
3498// through the same terminal writer as redraws while holding the TUI mutex,
3499// so a BEL cannot be interleaved with a frame update.
3500func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
3501 i.mu.Lock()
3502 i.emitAlertLocked(alert)
3503 i.mu.Unlock()
3504}
3505
3506func terminalAlertsEnabled(enabled *bool) bool {
3507 return enabled == nil || *enabled
3508}
3509
3510// emitAlertLocked applies the shared terminal-alert policy. The caller must
3511// hold i.mu because terminal writes share the renderer's output boundary.
3512func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
3513 if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
3514 return
3515 }
3516 _ = tui.WriteBell(i.cfg.Terminal)
3517}
3518
3519// scheduleMainAlert defers a main-session alert until the next redraw
3520// commits the final frame. Keeping this deferred even when no paced text is
3521// pending avoids racing the pacer's final state transition.
3522func (i *Interactive) scheduleMainAlert(reason string) {
3523 if reason == "" {
3524 return
3525 }
3526 i.mu.Lock()
3527 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3528 i.mu.Unlock()
3529 i.invalidate()
3530}
3531
3532// ClearNotes removes every note line owned by extName from the
3533// bottom-sticky ext-notes block. Extensions use this to retract a
3534// transient status line (e.g. an approval prompt) once it no longer
3535// applies, instead of leaving it stacked forever. Notes from other
3536// extensions and internal notes (auto-compact) are left untouched.
3537func (i *Interactive) ClearNotes(extName string) {
3538 marker := "[" + extName + "] "
3539 i.mu.Lock()
3540 if len(i.extNotes) == 0 {
3541 i.mu.Unlock()
3542 return
3543 }
3544 kept := i.extNotes[:0:0]
3545 changed := false
3546 for _, line := range i.extNotes {
3547 if strings.Contains(line, marker) {
3548 changed = true
3549 continue
3550 }
3551 kept = append(kept, line)
3552 }
3553 if changed {
3554 i.extNotes = kept
3555 }
3556 i.mu.Unlock()
3557 if changed {
3558 i.invalidate()
3559 }
3560}
3561
3562// Submit feeds text through the agent loop as if the user had typed it.
3563func (i *Interactive) Submit(text string) {
3564 if cmd, ok := shellEscapeCommand(text); ok {
3565 i.startShellEscape(i.runCtx, cmd)
3566 return
3567 }
3568 parent := i.runCtx
3569 if parent == nil {
3570 parent = context.Background()
3571 }
3572 i.mu.Lock()
3573 awaitingStartupPre := i.awaitingStartupPre
3574 i.mu.Unlock()
3575 if !awaitingStartupPre {
3576 i.maybeStartSessionTitle(parent, text)
3577 }
3578 i.startTurn(parent, text)
3579}
3580
3581// completeStartupPre applies deferred InitialInput after entry.pre finishes.
3582// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
3583// it only pre-fills the editor (CLI-supplied prompts).
3584func (i *Interactive) completeStartupPre() {
3585 i.mu.Lock()
3586 if !i.awaitingStartupPre {
3587 i.mu.Unlock()
3588 return
3589 }
3590 i.awaitingStartupPre = false
3591 deferred := i.deferredInitialInput
3592 auto := i.autoSubmitDeferred
3593 i.deferredInitialInput = ""
3594 i.autoSubmitDeferred = false
3595 onDone := i.cfg.OnStartupPreDone
3596 i.mu.Unlock()
3597 if onDone != nil {
3598 onDone()
3599 }
3600 i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3601 i.invalidate()
3602}
3603
3604// applyStartupPreResult runs on the TUI event loop so the editor remains
3605// single-threaded. Input entered while resources were reloading wins over the
3606// deferred prefill rather than being overwritten.
3607func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3608 if result.deferred == "" {
3609 return
3610 }
3611 if result.autoSubmit {
3612 i.Submit(result.deferred)
3613 return
3614 }
3615 if i.ed.IsEmpty() {
3616 i.ed.SetValue(result.deferred)
3617 }
3618}
3619
3620// ApplySessionAgent swaps the live agent after a validated session resume.
3621// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
3622// context; the session loader is changing the transcript/provider only.
3623func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3624 if ag == nil {
3625 return
3626 }
3627 i.mu.Lock()
3628 i.agent = ag
3629 i.cfg.Provider = providerName
3630 i.cfg.Model = model
3631 i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
3632 i.cumUsage = ag.Cost()
3633 last := ag.LastTurnUsage()
3634 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
3635 if len(i.view.Messages) > initialResumeTailLimit {
3636 i.view.TailLimit = initialResumeTailLimit
3637 } else {
3638 i.view.TailLimit = 0
3639 }
3640 i.view.InvalidateRenderCache()
3641 i.toolCalls = map[string]*tui.ToolCallView{}
3642 i.toolOrder = nil
3643 i.toolGate = map[string]int{}
3644 i.helpBlock = nil
3645 i.extNotes = nil
3646 i.parkedTurn = 0
3647 i.parkedTotal = 0
3648 i.mu.Unlock()
3649 i.invalidate()
3650}
3651
3652// ApplyChangedCWD is called by hosts after a successful /cd hook that do
3653// not provide startup context metadata.
3654func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
3655 i.applyChangedCWD(ag, provider, model, cwd, nil)
3656}
3657
3658// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
3659// startup context into the running TUI after a successful /cd hook.
3660func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3661 i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
3662}
3663
3664func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3665 home, _ := os.UserHomeDir()
3666 profiles, _ := subagents.Discover(cwd, home)
3667 subagentsAddendum := subagents.SystemPromptAddendum(profiles)
3668
3669 i.mu.Lock()
3670 i.agent = ag
3671 i.cfg.CWD = cwd
3672 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3673 if i.autoSubagentsEnabledLocked() {
3674 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
3675 } else {
3676 i.managedAutoSubagentsAddenda = nil
3677 }
3678 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3679 i.view.StartupContextPaths = nil
3680 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3681 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3682 }
3683 // Re-report the working directory to the terminal so "new tab here"
3684 // tracks the /cd change (OSC 7, see issue #38).
3685 if i.cfg.Terminal != nil {
3686 if seq := tui.ReportCWD(cwd); seq != "" {
3687 _, _ = i.cfg.Terminal.Write([]byte(seq))
3688 }
3689 }
3690 i.cfg.Provider = provider
3691 i.cfg.Model = model
3692 titleCancel := i.titleCancel
3693 i.titleCancel = nil
3694 i.titleVersion++
3695 i.sessionTitle = ""
3696 i.titleRealPromptSeen = false
3697 i.titleGenerationStarted = false
3698 i.writeTerminalTitleLocked("")
3699 i.toolCalls = map[string]*tui.ToolCallView{}
3700 i.toolOrder = nil
3701 i.toolGate = map[string]int{}
3702 i.helpBlock = nil
3703 i.parkedTurn = 0
3704 i.statusErr = ""
3705 i.mu.Unlock()
3706 if titleCancel != nil {
3707 titleCancel()
3708 }
3709 i.fileSuggest.Reset()
3710 i.fileSuggest.SetCWD(cwd)
3711 i.invalidate()
3712}
3713
3714// SubmitSlash runs text as a slash command in the TUI as if the user
3715// had typed it. text must start with '/' — callers that hand it
3716// plain prose silently get a no-op so a misbehaving extension can't
3717// run a stray prompt through this path. Read-only commands run in
3718// place; commands that would mutate the transcript or replace the
3719// agent cancel the active turn first via the same path the editor
3720// uses for typed slash commands.
3721func (i *Interactive) SubmitSlash(text string) {
3722 i.mu.Lock()
3723 i.doubleEscape.Reset()
3724 i.mu.Unlock()
3725 text = strings.TrimSpace(text)
3726 if !strings.HasPrefix(text, "/") {
3727 return
3728 }
3729 if slashCommandCancelsTurn(text) {
3730 i.cancelAndWaitForIdle()
3731 }
3732 i.runSlash(i.runCtx, text)
3733 i.invalidate()
3734}
3735
3736// SubmitOrQueue runs text immediately if the agent is idle, or
3737// appends it to the pending queue if a turn is already in flight.
3738// Used by the telegram bridge (and by the editor submit path) so
3739// both input sources share the same "queue behind an active turn"
3740// semantics. Images are ignored for now — only the text prompt is
3741// forwarded — because the queued-prompt path is text-only; a
3742// follow-up can expand the queue entry to carry images.
3743func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3744 if cmd, ok := shellEscapeCommand(text); ok {
3745 i.startShellEscape(i.runCtx, cmd)
3746 return
3747 }
3748 i.mu.Lock()
3749 if i.agent == nil {
3750 i.statusErr = "not logged in. type /login first."
3751 i.mu.Unlock()
3752 i.invalidate()
3753 return
3754 }
3755 i.mu.Unlock()
3756 i.maybeStartSessionTitle(i.runCtx, text)
3757 i.mu.Lock()
3758 if i.busy {
3759 // Queue text only; images are dropped for queued prompts. Keep the
3760 // interactive mutex held through Agent.QueueMessage so turn teardown
3761 // cannot inspect the agent queue between the busy check and enqueue.
3762 // Compaction uses the host queue because it has no active agent loop
3763 // to drain Agent.QueueMessage entries.
3764 if i.agent != nil && !i.compacting {
3765 i.agent.QueueMessage(text)
3766 } else {
3767 i.queued = append(i.queued, text)
3768 }
3769 i.mu.Unlock()
3770 i.invalidate()
3771 return
3772 }
3773 i.mu.Unlock()
3774 i.startTurnWithImages(i.runCtx, text, images)
3775}
3776
3777// CancelTurn aborts the active turn if one is running. Used by the
3778// telegram bridge when the paired user sends /stop.
3779// ChangelogVersion returns the version string of the changelog
3780// currently shown (or last shown). Used by the dismiss callback
3781// to store the correct version for dev builds.
3782func (i *Interactive) ChangelogVersion() string {
3783 if i.changelogDialog != nil {
3784 return i.changelogDialog.version
3785 }
3786 return ""
3787}
3788
3789func (i *Interactive) CancelTurn() {
3790 i.mu.Lock()
3791 cancel := i.cancelTurn
3792 i.mu.Unlock()
3793 if cancel != nil {
3794 cancel()
3795 i.confirmDialog.CancelAll("turn cancelled")
3796 }
3797}
3798
3799// Insert places text at the cursor in the editor.
3800func (i *Interactive) Insert(text string) {
3801 i.ed.Insert(text)
3802 i.invalidate()
3803}
3804
3805// Display appends a styled note from extName to the chat without a
3806// model call.
3807func (i *Interactive) Display(extName, text string) {
3808 i.appendExtensionNote(extName, text, "info")
3809 i.invalidate()
3810}
3811
3812// ReportError surfaces a host-side error in the interactive status area.
3813func (i *Interactive) ReportError(err error) {
3814 if err == nil {
3815 return
3816 }
3817 i.mu.Lock()
3818 i.statusOK = ""
3819 i.statusErr = err.Error()
3820 i.mu.Unlock()
3821 i.invalidate()
3822}
3823
3824// SetStatus replaces one persistent status item owned by an extension.
3825func (i *Interactive) SetStatus(extName, key, level, text string) {
3826 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
3827 return
3828 }
3829 i.mu.Lock()
3830 if i.extStatuses == nil {
3831 i.extStatuses = map[string]map[string]extensionStatus{}
3832 }
3833 if strings.TrimSpace(text) == "" {
3834 if items := i.extStatuses[extName]; items != nil {
3835 delete(items, key)
3836 if len(items) == 0 {
3837 delete(i.extStatuses, extName)
3838 }
3839 }
3840 } else {
3841 if i.extStatuses[extName] == nil {
3842 i.extStatuses[extName] = map[string]extensionStatus{}
3843 }
3844 i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
3845 }
3846 i.mu.Unlock()
3847 i.invalidate()
3848}
3849
3850// SetWidget replaces one persistent widget owned by an extension.
3851func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
3852 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3853 return
3854 }
3855 position = extproto.NormalizeWidgetPosition(position)
3856 i.mu.Lock()
3857 if i.extWidgets == nil {
3858 i.extWidgets = map[string]map[string]extensionWidget{}
3859 }
3860 if i.extWidgets[extName] == nil {
3861 i.extWidgets[extName] = map[string]extensionWidget{}
3862 }
3863 i.extWidgets[extName][id] = extensionWidget{
3864 Position: position,
3865 Title: title,
3866 Lines: append([]string(nil), lines...),
3867 }
3868 i.mu.Unlock()
3869 i.invalidate()
3870}
3871
3872// ClearWidget removes one persistent widget owned by an extension.
3873func (i *Interactive) ClearWidget(extName, id string) {
3874 i.mu.Lock()
3875 if items := i.extWidgets[extName]; items != nil {
3876 delete(items, id)
3877 if len(items) == 0 {
3878 delete(i.extWidgets, extName)
3879 }
3880 }
3881 i.mu.Unlock()
3882 i.invalidate()
3883}
3884
3885// ClearExtensionChrome removes every persistent UI item owned by an
3886// extension that has exited. This keeps crashes, disable/reload operations,
3887// and startup failures from leaving stale widgets beside the transcript.
3888func (i *Interactive) ClearExtensionChrome(extName string) {
3889 if strings.TrimSpace(extName) == "" {
3890 return
3891 }
3892 marker := "[" + extName + "] "
3893 i.mu.Lock()
3894 changed := false
3895 if _, ok := i.extStatuses[extName]; ok {
3896 delete(i.extStatuses, extName)
3897 changed = true
3898 }
3899 if _, ok := i.extWidgets[extName]; ok {
3900 delete(i.extWidgets, extName)
3901 changed = true
3902 }
3903 if len(i.extNotes) > 0 {
3904 kept := i.extNotes[:0:0]
3905 for _, line := range i.extNotes {
3906 if strings.Contains(line, marker) {
3907 changed = true
3908 continue
3909 }
3910 kept = append(kept, line)
3911 }
3912 i.extNotes = kept
3913 }
3914 if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
3915 i.extPanel.Close()
3916 changed = true
3917 }
3918 i.mu.Unlock()
3919 if changed {
3920 i.invalidate()
3921 }
3922}
3923
3924func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3925 i.mu.Lock()
3926 defer i.mu.Unlock()
3927 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
3928 i.confirmDialog.Blur()
3929 if i.cfg.Extensions != nil {
3930 cols, rows := i.cfg.Terminal.Size()
3931 _ = cols
3932 _ = rows
3933 }
3934 i.invalidate()
3935}
3936
3937func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
3938 i.mu.Lock()
3939 defer i.mu.Unlock()
3940 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3941 i.extPanel.Update(title, lines, footer)
3942 i.invalidate()
3943 }
3944}
3945
3946func (i *Interactive) ClosePanel(extName, panelID string) {
3947 i.mu.Lock()
3948 defer i.mu.Unlock()
3949 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3950 i.extPanel.Close()
3951 i.invalidate()
3952 }
3953}
3954
3955func effectiveImageProtocol(override *bool) tui.ImageProtocol {
3956 detected := tui.DetectImageProtocol()
3957 if override == nil {
3958 return detected
3959 }
3960 if !*override {
3961 return tui.ImageProtocolNone
3962 }
3963 return detected
3964}
3965
3966func imageProtocolName(p tui.ImageProtocol) string {
3967 switch p {
3968 case tui.ImageProtocolKitty:
3969 return "kitty/ghostty"
3970 case tui.ImageProtocolITerm2:
3971 return "iTerm2"
3972 default:
3973 return "none"
3974 }
3975}
3976
3977func onOff(v bool) string {
3978 if v {
3979 return "enabled"
3980 }
3981 return "disabled"
3982}
3983
3984const modalBackdropDimPercent = 50
3985
3986func (i *Interactive) applyInputCursorColor() {
3987 if i == nil || i.cfg.Terminal == nil {
3988 return
3989 }
3990 color := tui.Color256(15)
3991 if i.cursorDimmed {
3992 color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
3993 }
3994 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
3995}
3996
3997// setInputCursorDimmed updates the terminal-owned cursor only when its layer
3998// changes. Reapplying cursor controls on every redraw can reset its blink
3999// timing before the terminal completes a normal blink cycle.
4000func (i *Interactive) setInputCursorDimmed(dimmed bool) {
4001 if i == nil || i.cursorDimmed == dimmed {
4002 return
4003 }
4004 i.cursorDimmed = dimmed
4005 i.applyInputCursorColor()
4006}
4007
4008func reasoningSettingOptions(levels []string) []settingsOption {
4009 descriptions := map[string]string{
4010 "": "no reasoning",
4011 "minimum": "very brief (~1k tokens)",
4012 "low": "light (~2k tokens)",
4013 "medium": "moderate (~8k tokens)",
4014 "high": "deep (~16k tokens)",
4015 "xhigh": "extra-high effort",
4016 "max": "unconstrained effort",
4017 }
4018 options := make([]settingsOption, 0, len(levels))
4019 for _, level := range levels {
4020 label := level
4021 if label == "" {
4022 label = "off"
4023 }
4024 options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
4025 }
4026 return options
4027}
4028
4029func (i *Interactive) reasoningSettingItem() settingsItem {
4030 levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
4031 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
4032 desc := "reasoning depth for reasoning-capable models"
4033 hint := ""
4034 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
4035 levels = provider.AvailableReasoningLevels(m)
4036 reasoning = provider.ClampReasoningForModel(m, reasoning)
4037 if !m.Reasoning {
4038 hint = "current model does not support reasoning"
4039 desc += "; current model does not support reasoning"
4040 } else if len(levels) == 1 {
4041 hint = "current model has no adjustable reasoning levels"
4042 desc += "; current model has no adjustable reasoning levels"
4043 } else {
4044 desc += "; only levels supported by the current model are shown"
4045 }
4046 }
4047 options := reasoningSettingOptions(levels)
4048 choice := 0
4049 for idx, opt := range options {
4050 if opt.value == reasoning {
4051 choice = idx
4052 break
4053 }
4054 }
4055
4056 return settingsItem{
4057 key: "reasoning",
4058 label: "reasoning level",
4059 desc: desc,
4060 options: options,
4061 choice: choice,
4062 hint: hint,
4063 }
4064}
4065
4066func (i *Interactive) openReasoningDialog() {
4067 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
4068}
4069
4070func (i *Interactive) openSettingsDialog() {
4071 detected := tui.DetectImageProtocol()
4072 imgEnabled := detected != tui.ImageProtocolNone
4073 if i.cfg.InlineImagesEnabled != nil {
4074 imgEnabled = *i.cfg.InlineImagesEnabled
4075 }
4076 imgDisabled := detected == tui.ImageProtocolNone
4077 imgHint := ""
4078 if imgDisabled {
4079 imgEnabled = false
4080 imgHint = "this terminal does not support inline images"
4081 } else {
4082 imgHint = "terminal supports " + imageProtocolName(detected)
4083 }
4084
4085 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
4086 terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
4087 autoSubagents := false
4088 if i.cfg.AutoSubagentsEnabled != nil {
4089 autoSubagents = *i.cfg.AutoSubagentsEnabled
4090 }
4091 autoSubagentsDisabled := !i.autoSubagentsAvailable()
4092 autoSubagentsHint := i.autoSubagentsUnavailableHint()
4093 if autoSubagentsDisabled {
4094 autoSubagents = false
4095 }
4096
4097 ponytailEnabled := i.ponytailEnabled()
4098 ponytailDisabled := !i.ponytailSettingsAvailable()
4099 ponytailHint := ""
4100 if ponytailDisabled {
4101 ponytailHint = "requires persistent settings and live prompt refresh support"
4102 }
4103
4104 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
4105 fastModeHint := "OpenAI service tier"
4106 if !provider.SupportsFastMode(i.cfg.Provider) {
4107 fastModeHint = "only supported for OpenAI providers"
4108 }
4109 lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4110 subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
4111
4112 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
4113 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
4114 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
4115 compactMode := i.compactModeEnabled()
4116 showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
4117 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
4118 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
4119 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4120 quickItems := i.quickModelSettingItems()
4121
4122 autoCompactOptions := []settingsOption{
4123 {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
4124 {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
4125 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
4126 {value: "85", label: "85%", desc: "default balance between history and headroom"},
4127 {value: "90", label: "90%", desc: "retain more history before compacting"},
4128 }
4129 autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4130 autoCompactChoice := 0
4131 for idx, opt := range autoCompactOptions {
4132 if opt.value == strconv.Itoa(autoCompactThreshold) {
4133 autoCompactChoice = idx
4134 break
4135 }
4136 }
4137
4138 reasoningItem := i.reasoningSettingItem()
4139
4140 themeName := i.cfg.ThemeName
4141 if themeName == "" {
4142 themeName = "auto"
4143 }
4144 if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
4145 themeName = "auto"
4146 i.cfg.ThemeName = ""
4147 if i.cfg.SettingsStore != nil {
4148 _ = i.cfg.SettingsStore.SetTheme("auto")
4149 }
4150 i.applyThemeNow("auto")
4151 }
4152 themeOptions := []settingsOption{}
4153 themeChoice := 0
4154 availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
4155 if i.cfg.ExtensionThemes != nil {
4156 availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
4157 }
4158 for idx, opt := range availableThemes {
4159 themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
4160 if opt.Value == themeName {
4161 themeChoice = idx
4162 }
4163 }
4164
4165 inputStyleOptions := []settingsOption{
4166 {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
4167 {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
4168 {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
4169 }
4170 inputStyleChoice := 0
4171 for idx, opt := range inputStyleOptions {
4172 if opt.value == inputStyle {
4173 inputStyleChoice = idx
4174 break
4175 }
4176 }
4177 statusPositionOptions := []settingsOption{
4178 {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
4179 {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
4180 }
4181 statusPositionChoice := 0
4182 for idx, opt := range statusPositionOptions {
4183 if opt.value == statusPosition {
4184 statusPositionChoice = idx
4185 break
4186 }
4187 }
4188
4189 workingPositionOptions := []settingsOption{
4190 {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
4191 {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
4192 }
4193 workingPositionChoice := 0
4194 for idx, opt := range workingPositionOptions {
4195 if opt.value == workingPosition {
4196 workingPositionChoice = idx
4197 break
4198 }
4199 }
4200
4201 items := []settingsItem{
4202 {
4203 key: "inline_images_enabled",
4204 label: "render images when supported",
4205 desc: "draw screenshots inline instead of showing a text placeholder",
4206 value: imgEnabled,
4207 disabled: imgDisabled,
4208 hint: imgHint,
4209 },
4210 {
4211 key: "terminal_alerts_enabled",
4212 label: "terminal alerts",
4213 desc: "ring the terminal when the main session needs attention or an extension raises an alert",
4214 value: terminalAlerts,
4215 },
4216 {
4217 key: "terminal_title_enabled",
4218 label: "AI terminal titles",
4219 desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
4220 value: terminalTitles,
4221 },
4222 {
4223 key: "auto_subagents_enabled",
4224 label: "auto-subagents",
4225 desc: "let the agent spawn background sub-agents and query their live state",
4226 value: autoSubagents,
4227 disabled: autoSubagentsDisabled,
4228 hint: autoSubagentsHint,
4229 },
4230 {
4231 key: "ponytail_enabled",
4232 label: "ponytail coding mode",
4233 desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4234 value: ponytailEnabled,
4235 disabled: ponytailDisabled,
4236 hint: ponytailHint,
4237 },
4238 {
4239 key: "fast_mode",
4240 label: "fast mode",
4241 desc: "request OpenAI's fast service tier; unsupported providers return an error",
4242 value: fastMode,
4243 hint: fastModeHint,
4244 },
4245 {
4246 key: "lsp_enabled",
4247 label: "lsp in main session",
4248 desc: "enable the lsp tool and code diagnostics for the main agent",
4249 value: lspEnabled,
4250 },
4251 {
4252 key: "subagent_lsp_enabled",
4253 label: "lsp in sub-agents",
4254 desc: "allow newly spawned background sub-agents to use the lsp tool",
4255 value: subagentLSPEnabled,
4256 },
4257 {
4258 key: "auto_compact_threshold",
4259 label: "auto-compact threshold",
4260 desc: "choose how full the model context can get before zut condenses conversation history",
4261 options: autoCompactOptions,
4262 choice: autoCompactChoice,
4263 },
4264 {
4265 key: "jail_by_default",
4266 label: "jail new sessions by default",
4267 desc: "confine tools to the session working directory unless /unjail is used",
4268 value: jailByDefault,
4269 },
4270 {
4271 key: "recursive_file_suggest",
4272 label: "recursive @-file search",
4273 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
4274 value: recursiveFiles,
4275 },
4276 {
4277 key: "respect_gitignore",
4278 label: "hide gitignored files in @-picker",
4279 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
4280 value: respectGitignore,
4281 },
4282 {
4283 key: "compact_mode",
4284 label: "compact transcript rendering",
4285 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
4286 value: compactMode,
4287 },
4288 {
4289 key: "show_instructions_at_startup",
4290 label: "show loaded resources at startup",
4291 desc: "list loaded context files, extensions, and user-installed skills above the transcript",
4292 value: showInstructions,
4293 },
4294 {
4295 key: "tui_settings",
4296 label: "tui settings",
4297 desc: "choose input chrome and where status information appears",
4298 children: []settingsItem{
4299 {
4300 key: "tui_input_style",
4301 label: "input style",
4302 desc: "choose between the plain prompt, lines, and a block input area",
4303 options: inputStyleOptions,
4304 choice: inputStyleChoice,
4305 },
4306 {
4307 key: "tui_status_position",
4308 label: "status position",
4309 desc: "place model, usage, and working directory above or below the input",
4310 options: statusPositionOptions,
4311 choice: statusPositionChoice,
4312 },
4313 {
4314 key: "tui_working_position",
4315 label: "working spinner position",
4316 desc: "place the working spinner above or below the input",
4317 options: workingPositionOptions,
4318 choice: workingPositionChoice,
4319 },
4320 },
4321 },
4322 reasoningItem,
4323 {
4324 key: "theme",
4325 label: "color theme",
4326 desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
4327 options: themeOptions,
4328 choice: themeChoice,
4329 },
4330 }
4331 if len(quickItems) > 0 {
4332 items = append(items, settingsItem{
4333 key: "quick_models",
4334 label: "model shortcuts",
4335 desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
4336 children: quickItems,
4337 })
4338 }
4339 i.settingsDialog.Open(items)
4340}
4341
4342func (i *Interactive) applySettingChange(act settingsAction) {
4343 switch {
4344 case strings.HasPrefix(act.Key, "quick_model_"):
4345 i.applyQuickModelSetting(act.Key, act.StringValue)
4346 case act.Key == "reasoning":
4347 i.applyReasoningSetting(act.StringValue)
4348 case act.Key == "theme":
4349 i.applyThemeSetting(act.StringValue)
4350 case act.Key == "auto_compact_threshold":
4351 i.applyAutoCompactThresholdSetting(act.StringValue)
4352 case act.Key == "tui_input_style":
4353 i.applyTUIInputStyleSetting(act.StringValue)
4354 case act.Key == "tui_status_position":
4355 i.applyTUIStatusPositionSetting(act.StringValue)
4356 case act.Key == "tui_working_position":
4357 i.applyTUIWorkingPositionSetting(act.StringValue)
4358 default:
4359 i.applySettingToggle(act.Key, act.Value)
4360 }
4361}
4362
4363func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
4364 threshold, err := strconv.Atoi(value)
4365 if err != nil {
4366 return
4367 }
4368 threshold = normalizeAutoCompactThreshold(&threshold)
4369 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
4370 if err := store.SetAutoCompactThreshold(threshold); err != nil {
4371 i.mu.Lock()
4372 i.statusErr = "settings: " + err.Error()
4373 i.mu.Unlock()
4374 return
4375 }
4376 }
4377 i.mu.Lock()
4378 i.cfg.AutoCompactThreshold = &threshold
4379 if threshold == 0 {
4380 i.statusOK = "auto-compact threshold off"
4381 } else {
4382 i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
4383 }
4384 i.statusErr = ""
4385 i.mu.Unlock()
4386 i.invalidate()
4387}
4388
4389func (i *Interactive) quickModelSettingItems() []settingsItem {
4390 if len(i.cfg.QuickModelShortcuts) < 9 {
4391 next := make([]QuickModelShortcut, 9)
4392 copy(next, i.cfg.QuickModelShortcuts)
4393 i.cfg.QuickModelShortcuts = next
4394 }
4395 items := make([]settingsItem, 0, 9)
4396 for slot := 1; slot <= 9; slot++ {
4397 items = append(items, i.quickModelSettingItem(slot))
4398 }
4399 return items
4400}
4401
4402func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
4403 current := QuickModelShortcut{}
4404 if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
4405 current = i.cfg.QuickModelShortcuts[slot-1]
4406 }
4407 hint := "not assigned"
4408 if current.Provider != "" && current.Model != "" {
4409 hint = current.Provider + " / " + current.Model
4410 }
4411 return settingsItem{
4412 key: "quick_model_" + strconv.Itoa(slot),
4413 label: "model " + strconv.Itoa(slot),
4414 desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
4415 picker: true,
4416 hint: hint,
4417 }
4418}
4419
4420func (i *Interactive) compactModeEnabled() bool {
4421 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
4422}
4423
4424func quickModelShortcutSlot(k tui.Key) int {
4425 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
4426 return 0
4427 }
4428 if runtime.GOOS == "darwin" {
4429 if !k.Super && !k.Ctrl {
4430 return 0
4431 }
4432 } else if !k.Ctrl {
4433 return 0
4434 }
4435 return int(k.Rune - '0')
4436}
4437
4438func quickModelShortcutPrefix() string {
4439 return "Ctrl"
4440}
4441
4442func quickModelShortcutLabel(slot int) string {
4443 return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
4444}
4445
4446func (i *Interactive) openQuickModelPicker(slot int) {
4447 if slot < 1 || slot > 9 {
4448 return
4449 }
4450 i.quickModelAssign = slot
4451 current := i.cfg.Model
4452 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
4453 current = i.cfg.QuickModelShortcuts[slot-1].Model
4454 }
4455 var loggedIn []string
4456 if i.cfg.LoggedInProviders != nil {
4457 loggedIn = i.cfg.LoggedInProviders()
4458 }
4459 i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
4460}
4461
4462func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
4463 i.setQuickModelShortcut(slot, providerName, model)
4464}
4465
4466func (i *Interactive) applyQuickModelShortcut(slot int) {
4467 if slot < 1 || slot > 9 {
4468 return
4469 }
4470 if i.busy {
4471 i.mu.Lock()
4472 i.statusErr = "cannot switch model while a turn is running"
4473 i.statusOK = ""
4474 i.mu.Unlock()
4475 i.invalidate()
4476 return
4477 }
4478 if len(i.cfg.QuickModelShortcuts) < slot {
4479 i.mu.Lock()
4480 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4481 i.statusOK = ""
4482 i.mu.Unlock()
4483 i.invalidate()
4484 return
4485 }
4486 shortcut := i.cfg.QuickModelShortcuts[slot-1]
4487 if shortcut.Provider == "" || shortcut.Model == "" {
4488 i.mu.Lock()
4489 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4490 i.statusOK = ""
4491 i.mu.Unlock()
4492 i.invalidate()
4493 return
4494 }
4495 i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
4496 i.invalidate()
4497}
4498
4499func (i *Interactive) applyQuickModelSetting(key, value string) {
4500 slotText := strings.TrimPrefix(key, "quick_model_")
4501 slot, err := strconv.Atoi(slotText)
4502 if err != nil || slot < 1 || slot > 9 {
4503 return
4504 }
4505 providerName, model := "", ""
4506 if value != "" {
4507 parts := strings.SplitN(value, "\t", 2)
4508 if len(parts) == 2 {
4509 providerName, model = parts[0], parts[1]
4510 }
4511 }
4512 i.setQuickModelShortcut(slot, providerName, model)
4513}
4514
4515func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
4516 if len(i.cfg.QuickModelShortcuts) < slot {
4517 next := make([]QuickModelShortcut, slot)
4518 copy(next, i.cfg.QuickModelShortcuts)
4519 i.cfg.QuickModelShortcuts = next
4520 }
4521 i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
4522 if i.cfg.SettingsStore != nil {
4523 if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
4524 i.mu.Lock()
4525 i.statusErr = "settings: " + err.Error()
4526 i.mu.Unlock()
4527 return
4528 }
4529 }
4530 i.mu.Lock()
4531 if model == "" {
4532 i.statusOK = quickModelShortcutLabel(slot) + " cleared"
4533 } else {
4534 i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
4535 }
4536 i.statusErr = ""
4537 i.mu.Unlock()
4538 i.refreshQuickModelSettingsItem(slot)
4539 i.invalidate()
4540}
4541
4542func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
4543 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
4544 return
4545 }
4546 key := "quick_model_" + strconv.Itoa(slot)
4547 for idx, it := range i.settingsDialog.items {
4548 if it.key == key {
4549 i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
4550 return
4551 }
4552 }
4553}
4554
4555func (i *Interactive) resetSettingsToggle(key string, value bool) {
4556 if i.settingsDialog == nil {
4557 return
4558 }
4559 for idx := range i.settingsDialog.items {
4560 if i.settingsDialog.items[idx].key == key {
4561 i.settingsDialog.items[idx].value = value
4562 return
4563 }
4564 }
4565}
4566
4567func (i *Interactive) ponytailEnabled() bool {
4568 return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
4569}
4570
4571func (i *Interactive) ponytailSettingsAvailable() bool {
4572 _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
4573 return persistent && i.cfg.RefreshPrompt != nil
4574}
4575
4576func (i *Interactive) applySettingToggle(key string, value bool) {
4577 // Every setting toggle forces a full repaint at the end — same
4578 // effect as the user pressing Ctrl+L — so any per-setting visual
4579 // change (image rendering, status copy, future toggles) lands
4580 // immediately instead of waiting for the next diff frame.
4581 defer func() {
4582 if i.rend != nil {
4583 i.rend.Clear()
4584 }
4585 i.invalidate()
4586 }()
4587 switch key {
4588 case "inline_images_enabled":
4589 val := value
4590 i.cfg.InlineImagesEnabled = &val
4591 if i.cfg.SettingsStore != nil {
4592 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
4593 i.mu.Lock()
4594 i.statusErr = "settings: " + err.Error()
4595 i.mu.Unlock()
4596 return
4597 }
4598 }
4599 i.mu.Lock()
4600 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
4601 i.view.InvalidateRenderCache()
4602 i.statusOK = "inline image rendering " + onOff(value)
4603 i.statusErr = ""
4604 i.mu.Unlock()
4605 case "terminal_alerts_enabled":
4606 val := value
4607 i.mu.Lock()
4608 i.cfg.TerminalAlertsEnabled = &val
4609 i.mu.Unlock()
4610 if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
4611 if err := store.SetTerminalAlertsEnabled(value); err != nil {
4612 i.mu.Lock()
4613 i.statusErr = "settings: " + err.Error()
4614 i.mu.Unlock()
4615 return
4616 }
4617 }
4618 i.mu.Lock()
4619 i.statusOK = "terminal alerts " + onOff(value)
4620 i.statusErr = ""
4621 i.mu.Unlock()
4622 case "terminal_title_enabled":
4623 val := value
4624 var cancel context.CancelFunc
4625 i.mu.Lock()
4626 i.cfg.TerminalTitleEnabled = &val
4627 if value {
4628 i.writeTerminalTitleLocked(i.sessionTitle)
4629 } else {
4630 cancel = i.titleCancel
4631 i.titleCancel = nil
4632 i.titleVersion++
4633 i.writeTerminalTitleLocked("")
4634 }
4635 i.mu.Unlock()
4636 if cancel != nil {
4637 cancel()
4638 }
4639 if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
4640 if err := store.SetTerminalTitleEnabled(value); err != nil {
4641 i.mu.Lock()
4642 i.statusErr = "settings: " + err.Error()
4643 i.mu.Unlock()
4644 return
4645 }
4646 }
4647 i.mu.Lock()
4648 i.statusOK = "AI terminal titles " + onOff(value)
4649 i.statusErr = ""
4650 i.mu.Unlock()
4651 case "ponytail_enabled":
4652 previous := i.ponytailEnabled()
4653 store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
4654 if !available || i.cfg.RefreshPrompt == nil {
4655 i.resetSettingsToggle(key, previous)
4656 i.mu.Lock()
4657 i.statusOK = ""
4658 i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
4659 i.mu.Unlock()
4660 return
4661 }
4662 if err := store.SetPonytailEnabled(value); err != nil {
4663 i.resetSettingsToggle(key, previous)
4664 i.mu.Lock()
4665 i.statusOK = ""
4666 i.statusErr = "settings: " + err.Error()
4667 i.mu.Unlock()
4668 return
4669 }
4670 val := value
4671 i.cfg.PonytailEnabled = &val
4672 if err := i.cfg.RefreshPrompt(); err != nil {
4673 errMsg := "settings: ponytail prompt refresh: " + err.Error()
4674 if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
4675 // The first write succeeded, so keep the in-memory setting at
4676 // the value that remains durable instead of claiming that the
4677 // rollback took effect.
4678 i.resetSettingsToggle(key, value)
4679 errMsg += "; rollback persistence: " + rollbackErr.Error()
4680 if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
4681 errMsg += "; durable-state refresh: " + reconcileErr.Error()
4682 }
4683 } else {
4684 previousVal := previous
4685 i.cfg.PonytailEnabled = &previousVal
4686 i.resetSettingsToggle(key, previous)
4687 if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
4688 errMsg += "; rollback refresh: " + refreshErr.Error()
4689 }
4690 }
4691 i.mu.Lock()
4692 i.statusOK = ""
4693 i.statusErr = errMsg
4694 i.mu.Unlock()
4695 return
4696 }
4697 i.mu.Lock()
4698 i.statusOK = "ponytail coding mode " + onOff(value)
4699 i.statusErr = ""
4700 i.mu.Unlock()
4701 case "auto_subagents_enabled":
4702 if value && !i.autoSubagentsAvailable() {
4703 i.mu.Lock()
4704 i.statusOK = ""
4705 i.statusErr = "auto-subagents unavailable: " + i.autoSubagentsUnavailableHint()
4706 i.mu.Unlock()
4707 return
4708 }
4709 val := value
4710 i.mu.Lock()
4711 i.cfg.AutoSubagentsEnabled = &val
4712 i.mu.Unlock()
4713 if i.cfg.SettingsStore != nil {
4714 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
4715 i.mu.Lock()
4716 i.statusErr = "settings: " + err.Error()
4717 i.mu.Unlock()
4718 return
4719 }
4720 }
4721 // Add/remove the auto-subagent tools on the live agent so the
4722 // model's tools[] list reflects the toggle on the next turn.
4723 // Without this the tools stay advertised after a disable and
4724 // the model keeps trying to call them.
4725 i.applyAutoSubagentsTool(value)
4726 // Also swap the system-prompt addendum in/out so the model
4727 // knows to use the tools proactively (or stops referencing them
4728 // after a disable).
4729 i.applyAutoSubagentsSystemPrompt(value)
4730 i.mu.Lock()
4731 i.statusOK = "auto-subagents " + onOff(value)
4732 i.statusErr = ""
4733 i.mu.Unlock()
4734 case "fast_mode":
4735 previous := i.cfg.FastMode != nil && *i.cfg.FastMode
4736 if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
4737 i.resetSettingsToggle(key, previous)
4738 i.mu.Lock()
4739 i.statusErr = "settings: " + err.Error()
4740 i.mu.Unlock()
4741 return
4742 }
4743 if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
4744 if err := store.SetFastMode(value); err != nil {
4745 i.resetSettingsToggle(key, previous)
4746 i.mu.Lock()
4747 i.statusErr = "settings: " + err.Error()
4748 i.mu.Unlock()
4749 return
4750 }
4751 }
4752 val := value
4753 i.cfg.FastMode = &val
4754 if i.agent != nil {
4755 i.agent.SetFastMode(value)
4756 }
4757 if i.cfg.Supervisor != nil {
4758 i.cfg.Supervisor.SetFastMode(value)
4759 }
4760 i.mu.Lock()
4761 i.statusOK = "fast mode " + onOff(value)
4762 i.statusErr = ""
4763 i.mu.Unlock()
4764 case "lsp_enabled":
4765 previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4766 store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
4767 if hasStore {
4768 if err := store.SetLSPEnabled(value); err != nil {
4769 i.mu.Lock()
4770 i.statusErr = "settings: " + err.Error()
4771 i.mu.Unlock()
4772 return
4773 }
4774 }
4775 val := value
4776 i.cfg.LSPEnabled = &val
4777 if i.cfg.RefreshTools != nil {
4778 if err := i.cfg.RefreshTools(); err != nil {
4779 errMsg := "settings: refresh tools: " + err.Error()
4780 rollbackErr := error(nil)
4781 if hasStore {
4782 rollbackErr = store.SetLSPEnabled(previous)
4783 }
4784 if rollbackErr != nil {
4785 // The first write succeeded, so keep the in-memory setting at
4786 // the value that remains durable instead of claiming that the
4787 // rollback took effect.
4788 errMsg += "; rollback persistence: " + rollbackErr.Error()
4789 if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
4790 errMsg += "; durable-state refresh: " + reconcileErr.Error()
4791 }
4792 } else {
4793 previousVal := previous
4794 i.cfg.LSPEnabled = &previousVal
4795 if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
4796 errMsg += "; rollback refresh: " + refreshErr.Error()
4797 }
4798 }
4799 i.mu.Lock()
4800 i.statusOK = ""
4801 i.statusErr = errMsg
4802 i.mu.Unlock()
4803 return
4804 }
4805 }
4806 i.mu.Lock()
4807 i.statusOK = "lsp in main session " + onOff(value)
4808 i.statusErr = ""
4809 i.mu.Unlock()
4810 case "subagent_lsp_enabled":
4811 if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4812 if err := store.SetSubagentLSPEnabled(value); err != nil {
4813 i.mu.Lock()
4814 i.statusErr = "settings: " + err.Error()
4815 i.mu.Unlock()
4816 return
4817 }
4818 }
4819 val := value
4820 i.cfg.SubagentLSPEnabled = &val
4821 i.mu.Lock()
4822 i.statusOK = "lsp in sub-agents " + onOff(value)
4823 i.statusErr = ""
4824 i.mu.Unlock()
4825 case "jail_by_default":
4826 val := value
4827 i.cfg.JailByDefault = &val
4828 if i.cfg.SettingsStore != nil {
4829 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
4830 i.mu.Lock()
4831 i.statusErr = "settings: " + err.Error()
4832 i.mu.Unlock()
4833 return
4834 }
4835 }
4836 if i.cfg.Sandbox != nil {
4837 if value {
4838 i.cfg.Sandbox.Lock()
4839 } else {
4840 i.cfg.Sandbox.Unlock()
4841 }
4842 }
4843 i.mu.Lock()
4844 i.statusOK = "jail by default " + onOff(value)
4845 i.statusErr = ""
4846 i.mu.Unlock()
4847 case "recursive_file_suggest":
4848 val := value
4849 i.cfg.RecursiveFileSuggest = &val
4850 if i.cfg.SettingsStore != nil {
4851 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
4852 i.mu.Lock()
4853 i.statusErr = "settings: " + err.Error()
4854 i.mu.Unlock()
4855 return
4856 }
4857 }
4858 // Flip the live picker so the next @ reflects the new mode
4859 // without restarting zut. SetRecursive drops its cache.
4860 i.fileSuggest.SetRecursive(value)
4861 i.mu.Lock()
4862 i.statusOK = "recursive @-file search " + onOff(value)
4863 i.statusErr = ""
4864 i.mu.Unlock()
4865 case "respect_gitignore":
4866 val := value
4867 i.cfg.RespectGitignore = &val
4868 if i.cfg.SettingsStore != nil {
4869 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
4870 i.mu.Lock()
4871 i.statusErr = "settings: " + err.Error()
4872 i.mu.Unlock()
4873 return
4874 }
4875 }
4876 i.fileSuggest.SetRespectGitignore(value)
4877 i.mu.Lock()
4878 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
4879 i.statusErr = ""
4880 i.mu.Unlock()
4881 case "compact_mode":
4882 val := value
4883 i.cfg.CompactMode = &val
4884 if i.cfg.SettingsStore != nil {
4885 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
4886 i.mu.Lock()
4887 i.statusErr = "settings: " + err.Error()
4888 i.mu.Unlock()
4889 return
4890 }
4891 }
4892 i.mu.Lock()
4893 i.view.CompactMode = value
4894 i.view.InvalidateRenderCache()
4895 i.statusOK = "compact transcript rendering " + onOff(value)
4896 i.statusErr = ""
4897 i.mu.Unlock()
4898 case "show_instructions_at_startup":
4899 val := value
4900 i.cfg.ShowInstructionsAtStartup = &val
4901 if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
4902 if err := store.SetShowInstructionsAtStartup(value); err != nil {
4903 i.mu.Lock()
4904 i.statusErr = "settings: " + err.Error()
4905 i.mu.Unlock()
4906 return
4907 }
4908 }
4909 i.mu.Lock()
4910 i.view.StartupAgentName = ""
4911 i.view.StartupContextPaths = nil
4912 i.view.StartupExtensionNames = nil
4913 i.view.StartupSkillNames = nil
4914 if value {
4915 i.view.StartupAgentName = i.cfg.StartupAgentName
4916 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
4917 i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
4918 i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
4919 }
4920 i.statusOK = "show loaded resources at startup " + onOff(value)
4921 i.statusErr = ""
4922 i.mu.Unlock()
4923 }
4924}
4925
4926func (i *Interactive) applyTUIInputStyleSetting(style string) {
4927 defer func() {
4928 if i.rend != nil {
4929 i.rend.Clear()
4930 }
4931 i.invalidate()
4932 }()
4933 style = tui.NormalizeInputStyle(style)
4934 i.cfg.TUIInputStyle = style
4935 i.applyInputCursorColor()
4936 if i.cfg.SettingsStore != nil {
4937 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
4938 i.mu.Lock()
4939 i.statusErr = "settings: " + err.Error()
4940 i.mu.Unlock()
4941 return
4942 }
4943 }
4944 i.mu.Lock()
4945 i.statusOK = "input style " + style
4946 i.statusErr = ""
4947 i.mu.Unlock()
4948}
4949
4950func (i *Interactive) applyTUIStatusPositionSetting(position string) {
4951 defer func() {
4952 if i.rend != nil {
4953 i.rend.Clear()
4954 }
4955 i.invalidate()
4956 }()
4957 position = tui.NormalizeStatusPosition(position)
4958 i.cfg.TUIStatusPosition = position
4959 if i.cfg.SettingsStore != nil {
4960 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
4961 i.mu.Lock()
4962 i.statusErr = "settings: " + err.Error()
4963 i.mu.Unlock()
4964 return
4965 }
4966 }
4967 i.mu.Lock()
4968 label := "above input"
4969 if position == tui.StatusPositionBelowInput {
4970 label = "below input"
4971 }
4972 i.statusOK = "status position " + label
4973 i.statusErr = ""
4974 i.mu.Unlock()
4975}
4976
4977func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
4978 defer func() {
4979 if i.rend != nil {
4980 i.rend.Clear()
4981 }
4982 i.invalidate()
4983 }()
4984 position = tui.NormalizeWorkingPosition(position)
4985 i.cfg.TUIWorkingPosition = position
4986 if i.cfg.SettingsStore != nil {
4987 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
4988 i.mu.Lock()
4989 i.statusErr = "settings: " + err.Error()
4990 i.mu.Unlock()
4991 return
4992 }
4993 }
4994 i.mu.Lock()
4995 label := "above input"
4996 if position == tui.WorkingPositionBelowInput {
4997 label = "below input"
4998 }
4999 i.statusOK = "working spinner position " + label
5000 i.statusErr = ""
5001 i.mu.Unlock()
5002}
5003
5004func (i *Interactive) applyThemeSetting(name string) {
5005 if i.cfg.SettingsStore != nil {
5006 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
5007 i.mu.Lock()
5008 i.statusErr = "settings: " + err.Error()
5009 i.mu.Unlock()
5010 return
5011 }
5012 }
5013 i.cfg.ThemeName = name
5014 if name == "auto" {
5015 i.cfg.ThemeName = ""
5016 }
5017 i.applyThemeNow(name)
5018}
5019
5020func (i *Interactive) applyThemeNow(name string) {
5021 if name == "" {
5022 name = "auto"
5023 }
5024 detected := tui.Dark
5025 if tui.IsLightTheme(i.cfg.Theme) {
5026 detected = tui.Light
5027 }
5028 // Keep the startup terminal snapshot available when a user switches
5029 // themes live; inherited mode must not issue OSC queries while the TUI
5030 // already owns raw stdin.
5031 detected.Terminal = i.cfg.Theme.Terminal
5032 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
5033 if err != nil {
5034 if i.cfg.SettingsStore != nil {
5035 _ = i.cfg.SettingsStore.SetTheme("auto")
5036 }
5037 i.cfg.ThemeName = ""
5038 th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
5039 i.mu.Lock()
5040 i.statusErr = "theme missing; reset to default"
5041 i.mu.Unlock()
5042 } else {
5043 i.mu.Lock()
5044 label := applied
5045 if label == "" {
5046 label = "auto"
5047 }
5048 i.statusOK = "theme " + label
5049 i.statusErr = ""
5050 i.mu.Unlock()
5051 }
5052 i.cfg.Theme = th
5053 i.view.Theme = th
5054 i.view.InvalidateRenderCache()
5055 i.ed.Prompt = th.AccentBar(th.Accent)
5056 i.applyInputCursorColor()
5057 i.spin.Configure(th)
5058 if i.rend != nil {
5059 i.rend.SetTheme(th)
5060 i.rend.Clear()
5061 }
5062 i.invalidate()
5063}
5064
5065func (i *Interactive) applyReasoningSetting(level string) {
5066 defer func() {
5067 if i.rend != nil {
5068 i.rend.Clear()
5069 }
5070 i.invalidate()
5071 }()
5072 level = provider.NormalizeReasoning(level)
5073 i.cfg.Reasoning = level
5074 if i.cfg.SettingsStore != nil {
5075 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
5076 i.mu.Lock()
5077 i.statusErr = "settings: " + err.Error()
5078 i.mu.Unlock()
5079 return
5080 }
5081 }
5082 i.mu.Lock()
5083 if i.agent != nil {
5084 i.agent.Reasoning = level
5085 }
5086 label := level
5087 if label == "" {
5088 label = "off"
5089 }
5090 i.statusOK = "reasoning level " + label
5091 i.statusErr = ""
5092 i.mu.Unlock()
5093}
5094
5095// buildStudyPrompt returns the canned prompt the /study command
5096// submits to the agent.
5097//
5098// With no argument, /study targets the current directory — the
5099// historical behaviour. With an argument, /study targets that path
5100// instead; either a directory ("read every file in here") or a
5101// single file ("read this file"). The argument can be:
5102//
5103// - a relative path (resolved against cwd)
5104// - an absolute path
5105// - an @-picker chip, which has already been expanded to an
5106// absolute path by expandFileChips before runSlash sees it
5107//
5108// The path is stat'd to pick the right wording ("directory" vs
5109// "file"). If the path doesn't exist, we still build a sensible
5110// prompt rather than erroring — the agent will surface the
5111// missing-file failure itself when it tries to read it, which is
5112// more useful than a refusal here.
5113func buildStudyPrompt(arg, cwd string) string {
5114 arg = strings.TrimSpace(arg)
5115 if arg == "" {
5116 return "Read and understand everything in the current directory."
5117 }
5118 abs := arg
5119 if !filepath.IsAbs(abs) {
5120 abs = filepath.Join(cwd, abs)
5121 }
5122 display := arg
5123 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
5124 display = rel
5125 }
5126 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
5127 return "Read and understand the file " + display + "."
5128 }
5129 return "Read and understand everything in the directory " + display + "."
5130}
5131
5132// tryPathTabCompleteEditor looks at ed's current value, finds the
5133// path-like token immediately before the cursor (the cursor is always
5134// at the end of the buffer after a keystroke, so "before the cursor"
5135// is the trailing non-whitespace run), and rewrites it to its shell-
5136// style completion against the filesystem.
5137//
5138// Returns true when it consumed the Tab keystroke (token recognised,
5139// completion attempted — even if no candidates matched, the keystroke
5140// is still consumed so it doesn't insert a literal tab character).
5141// Returns false when the token doesn't look like a path; callers then
5142// let Tab fall through to its normal no-op.
5143//
5144// Recognised path shapes:
5145// - ~ or ~/foo expanded via os.UserHomeDir()
5146// - /abs/path or /abs/path/foo absolute
5147// - ./foo, ../foo, foo/bar relative to cwd
5148//
5149// A bare word like "hello" is not treated as a path so plain text
5150// keeps Tab as a literal no-op.
5151//
5152// Free function (not a method) so the same logic runs against the
5153// editor instances owned by btwDialog and subagentsDialog without each
5154// dialog needing its own copy.
5155func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
5156 if ed == nil {
5157 return false
5158 }
5159 val := ed.Value()
5160 // Find the trailing run of non-whitespace.
5161 start := len(val)
5162 for start > 0 {
5163 r := val[start-1]
5164 if r == ' ' || r == '\t' || r == '\n' {
5165 break
5166 }
5167 start--
5168 }
5169 token := val[start:]
5170 if token == "" {
5171 return false
5172 }
5173 if !looksLikePathToken(token) {
5174 return false
5175 }
5176
5177 // Resolve the absolute parent directory + base prefix to match.
5178 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
5179 if !ok {
5180 return true
5181 }
5182 entries, err := os.ReadDir(parentAbs)
5183 if err != nil {
5184 return true
5185 }
5186 var names []string
5187 var isDir []bool
5188 for _, e := range entries {
5189 name := e.Name()
5190 if !strings.HasPrefix(name, basePrefix) {
5191 continue
5192 }
5193 // Hide dotfiles unless the user explicitly typed a leading dot,
5194 // mirroring bash's default behaviour.
5195 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
5196 continue
5197 }
5198 names = append(names, name)
5199 isDir = append(isDir, e.IsDir())
5200 }
5201 if len(names) == 0 {
5202 return true
5203 }
5204
5205 var completed string
5206 var completedIsDir bool
5207 if len(names) == 1 {
5208 completed = names[0]
5209 completedIsDir = isDir[0]
5210 } else {
5211 completed = longestCommonPrefix(names)
5212 if completed == basePrefix {
5213 // Already at the deepest unambiguous prefix; nothing to add.
5214 return true
5215 }
5216 }
5217
5218 // Build the replacement token in the same display form the user
5219 // typed (preserve ~ vs absolute vs relative).
5220 newToken := displayParent + completed
5221 if len(names) == 1 && completedIsDir {
5222 newToken += "/"
5223 }
5224
5225 ed.SetValue(val[:start] + newToken)
5226 return true
5227}
5228
5229// tryPathTabComplete is the Interactive-bound convenience wrapper.
5230// It calls the free helper against the main editor and invalidates
5231// the frame on a successful rewrite.
5232func (i *Interactive) tryPathTabComplete() bool {
5233 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
5234 i.invalidate()
5235 return true
5236 }
5237 return false
5238}
5239
5240// looksLikePathToken reports whether tok is shaped like a filesystem
5241// path. Paths must either start with ~, /, ./, ../ or contain a /.
5242// Plain words are excluded so Tab on "hello" stays a no-op.
5243func looksLikePathToken(tok string) bool {
5244 if tok == "" {
5245 return false
5246 }
5247 if tok[0] == '~' || tok[0] == '/' {
5248 return true
5249 }
5250 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
5251 return true
5252 }
5253 return strings.Contains(tok, "/")
5254}
5255
5256// resolvePathTabToken splits tok into (absolute parent dir, basename
5257// prefix to match, display-form parent the user typed). ok is false
5258// when the parent dir can't be resolved (e.g. ~ with no $HOME).
5259func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
5260 // Detect ~ expansion.
5261 expanded := tok
5262 homePrefix := ""
5263 if tok == "~" {
5264 home, err := os.UserHomeDir()
5265 if err != nil || home == "" {
5266 return "", "", "", false
5267 }
5268 // "~" alone: complete in $HOME. parent = home, base = "".
5269 return home, "", "~/", true
5270 }
5271 if strings.HasPrefix(tok, "~/") {
5272 home, err := os.UserHomeDir()
5273 if err != nil || home == "" {
5274 return "", "", "", false
5275 }
5276 expanded = home + tok[1:]
5277 homePrefix = "~"
5278 }
5279
5280 dir, base := splitDirBase(expanded)
5281 if !filepath.IsAbs(dir) {
5282 dir = filepath.Join(cwd, dir)
5283 }
5284
5285 // Reconstruct the display form the user typed for the parent,
5286 // keeping ~ when they used it. The base is dropped — the caller
5287 // substitutes the completed name.
5288 display := tok[:len(tok)-len(base)]
5289 if homePrefix != "" && !strings.HasPrefix(display, "~") {
5290 display = homePrefix + display[len(homePrefix):]
5291 }
5292 return dir, base, display, true
5293}
5294
5295// splitDirBase is like filepath.Split but preserves the trailing
5296// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
5297// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
5298// the trailing separator when non-empty so callers can rebuild paths
5299// by concatenation.
5300func splitDirBase(p string) (dir, base string) {
5301 if p == "" {
5302 return ".", ""
5303 }
5304 i := strings.LastIndex(p, "/")
5305 if i < 0 {
5306 return ".", p
5307 }
5308 return p[:i+1], p[i+1:]
5309}
5310
5311func longestCommonPrefix(ss []string) string {
5312 if len(ss) == 0 {
5313 return ""
5314 }
5315 prefix := ss[0]
5316 for _, s := range ss[1:] {
5317 n := 0
5318 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
5319 n++
5320 }
5321 prefix = prefix[:n]
5322 if prefix == "" {
5323 return ""
5324 }
5325 }
5326 return prefix
5327}
5328
5329func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5330 parts := strings.Fields(cmd)
5331 if len(parts) == 0 {
5332 return false
5333 }
5334
5335 if isSkillCommand(cmd) {
5336 var available []*skills.Skill
5337 if i.cfg.SkillSnapshot != nil {
5338 available = i.cfg.SkillSnapshot()
5339 }
5340 prompt, _, err := expandSkillCommand(cmd, available)
5341 if err != nil {
5342 i.mu.Lock()
5343 i.statusErr = err.Error()
5344 i.statusOK = ""
5345 i.mu.Unlock()
5346 return false
5347 }
5348 i.submitOrQueuePrompt(ctx, prompt)
5349 return false
5350 }
5351
5352 switch strings.ToLower(parts[0]) {
5353 case "/exit":
5354 return true
5355 case "/clear":
5356 if i.agent != nil {
5357 i.agent.SetMessages(nil)
5358 }
5359 i.mu.Lock()
5360 i.toolCalls = map[string]*tui.ToolCallView{}
5361 i.toolOrder = nil
5362 i.toolGate = map[string]int{}
5363 i.statusErr = ""
5364 i.statusOK = ""
5365 i.helpBlock = nil
5366 i.parkedTurn = 0
5367 i.parkedTotal = 0
5368 i.scrollOffset = 0
5369 i.extNotes = nil
5370 i.reloadErrors = nil
5371 i.view.InvalidateRenderCache()
5372 i.mu.Unlock()
5373 case "/help":
5374 i.mu.Lock()
5375 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
5376 i.statusErr = ""
5377 i.statusOK = ""
5378 // Pin the viewport to the newest content so the help block,
5379 // which we just appended to the end of the transcript, is
5380 // what the user actually sees.
5381 i.scrollOffset = 0
5382 i.mu.Unlock()
5383 case "/login":
5384 i.dialog.Open(i.cfg.ZutHome)
5385 case "/logout":
5386 if len(parts) >= 2 {
5387 // Explicit target: /logout anthropic | openai | all
5388 i.doLogout(parts[1])
5389 break
5390 }
5391 // No arg: open the picker over whichever providers are
5392 // currently logged in. If nothing's logged in, bail with a
5393 // status line.
5394 i.openLogoutDialog()
5395 case "/model":
5396 if len(parts) >= 2 {
5397 i.applyModelSelection("", parts[1])
5398 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
5399 i.mu.Lock()
5400 i.modelRefreshing = true
5401 i.statusOK = "Refreshing models"
5402 i.statusErr = ""
5403 i.mu.Unlock()
5404 go func() {
5405 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
5406 defer cancel()
5407 i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
5408 }()
5409 } else {
5410 i.openModelPickerAfterRefresh(nil)
5411 }
5412 case "/llama":
5413 if i.cfg.LlamaCPPConfig == nil {
5414 i.mu.Lock()
5415 i.statusErr = "llama.cpp configuration is unavailable"
5416 i.statusOK = ""
5417 i.mu.Unlock()
5418 break
5419 }
5420 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
5421 if configErr == nil {
5422 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
5423 }
5424 if configErr != nil {
5425 i.mu.Lock()
5426 i.statusErr = configErr.Error()
5427 i.statusOK = ""
5428 i.mu.Unlock()
5429 }
5430 case "/reasoning":
5431 i.openReasoningDialog()
5432 case "/settings":
5433 i.openSettingsDialog()
5434 case "/sessions":
5435 i.mu.Lock()
5436 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5437 i.mu.Unlock()
5438 case "/fork":
5439 i.doSessionFork()
5440 case "/jump":
5441 i.openJumpDialog(parts[1:])
5442 case "/btw":
5443 i.openBtwDialog(parts[1:])
5444 case "/skills":
5445 i.openSkillsDialog()
5446 case "/compact":
5447 i.runCompact(ctx, false)
5448 case "/study":
5449 // Canned prompt that tells the agent to read every file
5450 // in some target so its later turns have the whole thing
5451 // in context. With no argument, the target is the current
5452 // directory. With an argument, the target is whatever the
5453 // user passed — typed by hand, drag-dropped, or selected
5454 // via the @ file picker (which is why we accept both files
5455 // and directories; the @-picker chips for both have already
5456 // been expanded to absolute paths by expandFileChips above).
5457 // Dispatched through the normal queue-or-start path so it
5458 // behaves identically to typing the prompt by hand.
5459 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
5460 i.mu.Lock()
5461 busy := i.busy
5462 compacting := i.compacting
5463 ag := i.agent
5464 i.mu.Unlock()
5465 if busy {
5466 if ag != nil && !compacting {
5467 ag.QueueMessage(studyPrompt)
5468 } else {
5469 i.mu.Lock()
5470 i.queued = append(i.queued, studyPrompt)
5471 i.mu.Unlock()
5472 }
5473 i.invalidate()
5474 break
5475 }
5476 i.startTurn(ctx, studyPrompt)
5477 case "/cd":
5478 // Hidden command: switch the running session's cwd. Not in
5479 // slash_suggest, not in /help. Used by the workspaces
5480 // extension's panel-key Enter handler so picking a row
5481 // jumps zut into that directory without relaunching.
5482 //
5483 // Recovers the raw argument (path) from the original cmd
5484 // string rather than parts, so paths with spaces survive.
5485 // The host's ChangeCWD hook handles validation, session
5486 // close + reopen, agent rebuild, sandbox re-rooting, and
5487 // re-jail-if-jailed semantics.
5488 if i.cfg.ChangeCWD == nil {
5489 i.mu.Lock()
5490 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
5491 i.mu.Unlock()
5492 break
5493 }
5494 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
5495 if path == "" {
5496 i.mu.Lock()
5497 i.statusErr = "/cd: missing path"
5498 i.mu.Unlock()
5499 break
5500 }
5501 if err := i.cfg.ChangeCWD(path); err != nil {
5502 i.mu.Lock()
5503 i.statusErr = "/cd: " + err.Error()
5504 i.statusOK = ""
5505 i.mu.Unlock()
5506 break
5507 }
5508 // ChangeCWD has already updated i.cfg.CWD and swapped the
5509 // agent + session. Reset transient TUI state so the new
5510 // session opens clean.
5511 i.mu.Lock()
5512 i.toolCalls = map[string]*tui.ToolCallView{}
5513 i.toolOrder = nil
5514 i.toolGate = map[string]int{}
5515 i.helpBlock = nil
5516 i.parkedTurn = 0
5517 i.statusOK = "cwd " + i.cfg.CWD
5518 i.statusErr = ""
5519 i.mu.Unlock()
5520 i.fileSuggest.Reset()
5521 i.fileSuggest.SetCWD(i.cfg.CWD)
5522 i.invalidate()
5523 case "/jail":
5524 if i.cfg.Sandbox == nil {
5525 i.mu.Lock()
5526 i.statusErr = "sandbox not available in this build"
5527 i.mu.Unlock()
5528 break
5529 }
5530 i.cfg.Sandbox.Lock()
5531 i.mu.Lock()
5532 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
5533 i.statusErr = ""
5534 i.mu.Unlock()
5535 case "/unjail":
5536 if i.cfg.Sandbox == nil {
5537 i.mu.Lock()
5538 i.statusErr = "sandbox not available in this build"
5539 i.mu.Unlock()
5540 break
5541 }
5542 i.cfg.Sandbox.Unlock()
5543 i.mu.Lock()
5544 i.statusOK = "unjailed"
5545 i.statusErr = ""
5546 i.mu.Unlock()
5547 case "/reload-ext":
5548 i.runReloadExt(ctx)
5549 case "/telegram", "/tg":
5550 if len(parts) >= 2 {
5551 i.doTelegram(parts[1])
5552 break
5553 }
5554 i.openTelegramDialog()
5555 case "/session":
5556 if len(parts) >= 2 {
5557 action := parts[1]
5558 arg := ""
5559 if len(parts) >= 3 {
5560 arg = strings.Join(parts[2:], " ")
5561 }
5562 i.doSessionOp(action, arg)
5563 break
5564 }
5565 i.openSessionOpsDialog()
5566 case "/subagents":
5567 i.runSubagents(ctx, parts[1:])
5568 default:
5569 // Last-resort fallback: try the extension manager. Built-in
5570 // cases above always win; this branch only fires for slash
5571 // commands the extension manager registered. Same routing as
5572 // the editor's submit-handler dispatch path so the autocomplete
5573 // "enter on highlighted suggestion" flow also works.
5574 extName := strings.TrimPrefix(parts[0], "/")
5575 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
5576 rest := ""
5577 if len(parts) > 1 {
5578 rest = strings.Join(parts[1:], " ")
5579 }
5580 go i.invokeExtensionCommand(ctx, extName, rest)
5581 return false
5582 }
5583 i.mu.Lock()
5584 i.statusErr = "unknown command: " + parts[0]
5585 i.mu.Unlock()
5586 }
5587 return false
5588}
5589
5590func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
5591 var loggedIn []string
5592 if i.cfg.LoggedInProviders != nil {
5593 loggedIn = i.cfg.LoggedInProviders()
5594 }
5595 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
5596 i.mu.Lock()
5597 if refreshErr != nil {
5598 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
5599 i.statusOK = ""
5600 } else if i.statusOK == "Refreshing models" {
5601 i.statusOK = ""
5602 }
5603 i.mu.Unlock()
5604}
5605
5606// openLogoutDialog shows the provider picker for `/logout` with no
5607// argument. Only providers the user is currently logged into are
5608// listed, plus an "all" entry when more than one is present. If
5609// nothing's logged in, writes a status line instead of opening an
5610// empty dialog.
5611func (i *Interactive) openLogoutDialog() {
5612 if i.cfg.AuthManager == nil {
5613 i.mu.Lock()
5614 i.statusErr = "no auth manager configured"
5615 i.mu.Unlock()
5616 i.invalidate()
5617 return
5618 }
5619 store := i.cfg.AuthManager.Store()
5620 if store == nil {
5621 i.mu.Lock()
5622 i.statusErr = "auth store is not available"
5623 i.mu.Unlock()
5624 i.invalidate()
5625 return
5626 }
5627 creds, err := store.Load()
5628 if err != nil {
5629 i.mu.Lock()
5630 i.statusErr = "read auth store: " + err.Error()
5631 i.mu.Unlock()
5632 i.invalidate()
5633 return
5634 }
5635
5636 var items []logoutItem
5637 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
5638 if creds.Has(p) {
5639 method := creds.Method(p)
5640 if method == "oauth" {
5641 method = "subscription"
5642 }
5643 items = append(items, logoutItem{
5644 label: providerLabel(p),
5645 target: p,
5646 method: method,
5647 })
5648 }
5649 }
5650 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
5651 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
5652 }
5653 if creds.OpenAI.OAuth != nil {
5654 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
5655 }
5656 for p, c := range creds.AdditionalAPIKeyCreds {
5657 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
5658 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
5659 }
5660 }
5661 if len(items) == 0 {
5662 i.mu.Lock()
5663 i.statusOK = "no credentials stored; already logged out"
5664 i.statusErr = ""
5665 i.mu.Unlock()
5666 i.invalidate()
5667 return
5668 }
5669 if len(items) > 1 {
5670 items = append(items, logoutItem{label: "all", target: "all"})
5671 }
5672
5673 i.logoutDialog.Open(items)
5674 i.invalidate()
5675}
5676
5677// doLogout clears credentials for the given provider (or all providers)
5678// from auth.json. If the active agent was using those credentials, it
5679// is torn down so the user is forced through /login before their next
5680// prompt.
5681//
5682// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
5683func (i *Interactive) doLogout(target string) {
5684 if i.cfg.AuthManager == nil {
5685 i.mu.Lock()
5686 i.statusErr = "no auth manager configured"
5687 i.mu.Unlock()
5688 return
5689 }
5690 store := i.cfg.AuthManager.Store()
5691 if store == nil {
5692 i.mu.Lock()
5693 i.statusErr = "auth store is not available"
5694 i.mu.Unlock()
5695 return
5696 }
5697
5698 var providers []string
5699 switch target {
5700 case "", "all":
5701 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
5702 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
5703 providers = []string{target}
5704 default:
5705 known := false
5706 for _, p := range auth.APIKeyProviders() {
5707 if target == p {
5708 known = true
5709 break
5710 }
5711 }
5712 if !known {
5713 i.mu.Lock()
5714 i.statusErr = "unknown provider: " + target
5715 i.mu.Unlock()
5716 return
5717 }
5718 providers = []string{target}
5719 }
5720
5721 var errs []string
5722 clearedCurrent := false
5723 for _, p := range providers {
5724 var err error
5725 switch p {
5726 case "openai":
5727 err = store.ClearAPIKey("openai")
5728 case "openai-codex":
5729 err = store.ClearOAuth("openai")
5730 default:
5731 err = store.Clear(p)
5732 }
5733 if err != nil {
5734 errs = append(errs, p+": "+err.Error())
5735 continue
5736 }
5737 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5738 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
5739 errs = append(errs, p+": "+err.Error())
5740 continue
5741 }
5742 }
5743 if p == i.cfg.Provider {
5744 clearedCurrent = true
5745 }
5746 }
5747
5748 llamaConfigured := i.llamaConfigured
5749 if i.cfg.LlamaCPPConfig != nil {
5750 baseURL, _, err := i.cfg.LlamaCPPConfig()
5751 llamaConfigured = err == nil && baseURL != ""
5752 }
5753
5754 i.mu.Lock()
5755 defer i.mu.Unlock()
5756 i.llamaConfigured = llamaConfigured
5757 if len(errs) > 0 {
5758 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
5759 return
5760 }
5761 i.statusErr = ""
5762 if clearedCurrent {
5763 // The running agent was using a credential we just wiped. Drop
5764 // it so prompts can't go out with the stale client, and hint at
5765 // /login. Close any LSP processes owned by its tools first.
5766 if i.agent != nil {
5767 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
5768 }
5769 i.agent = nil
5770 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
5771 } else {
5772 i.statusOK = "logged out of " + strings.Join(providers, ", ")
5773 }
5774}
5775
5776func providerSetupInfo(provider string) (string, []string, bool) {
5777 const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
5778 switch provider {
5779 case "amazon-bedrock":
5780 return "Amazon Bedrock setup", []string{
5781 "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
5782 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
5783 "",
5784 "For Bedrock API keys, set:",
5785 " AWS_BEARER_TOKEN_BEDROCK=...",
5786 " AWS_REGION=us-east-1",
5787 "",
5788 "Docs:",
5789 " " + docsURL,
5790 }, true
5791 case "google-vertex":
5792 return "Google Vertex AI setup", []string{
5793 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
5794 "Set a Google API key, application-default credentials, or a service account.",
5795 "",
5796 "Common environment:",
5797 " GOOGLE_CLOUD_API_KEY=...",
5798 " GOOGLE_CLOUD_PROJECT=...",
5799 " GOOGLE_CLOUD_LOCATION=us-central1",
5800 "",
5801 "Docs:",
5802 " " + docsURL,
5803 }, true
5804 case "cloudflare-workers-ai":
5805 return "Cloudflare Workers AI setup", []string{
5806 "Cloudflare Workers AI needs both an API token and an account ID.",
5807 "",
5808 "Set:",
5809 " CLOUDFLARE_API_KEY=...",
5810 " CLOUDFLARE_ACCOUNT_ID=...",
5811 "",
5812 "Docs:",
5813 " " + docsURL,
5814 }, true
5815 case "cloudflare-ai-gateway":
5816 return "Cloudflare AI Gateway setup", []string{
5817 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
5818 "",
5819 "Set:",
5820 " CLOUDFLARE_API_KEY=...",
5821 " CLOUDFLARE_ACCOUNT_ID=...",
5822 " CLOUDFLARE_GATEWAY_ID=...",
5823 "",
5824 "Docs:",
5825 " " + docsURL,
5826 }, true
5827 case "azure-openai-responses":
5828 return "Azure OpenAI Responses setup", []string{
5829 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
5830 "",
5831 "Set:",
5832 " AZURE_OPENAI_API_KEY=...",
5833 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
5834 " AZURE_OPENAI_API_VERSION=2024-02-01",
5835 "",
5836 "Docs:",
5837 " " + docsURL,
5838 }, true
5839 default:
5840 return "", nil, false
5841 }
5842}
5843
5844func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
5845 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
5846 i.dialog.ShowResult(false, "auth store is unavailable")
5847 return
5848 }
5849 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
5850 i.dialog.ShowResult(false, err.Error())
5851 return
5852 }
5853 i.mu.Lock()
5854 i.llamaConfigured = true
5855 i.statusErr = ""
5856 i.statusOK = "configured llama.cpp router"
5857 i.mu.Unlock()
5858 i.dialog.ShowResult(true, "")
5859}
5860
5861func (i *Interactive) startAPIKeyFlow(provider string) {
5862 if title, lines, ok := providerSetupInfo(provider); ok {
5863 i.dialog.ShowInfo(title, lines)
5864 return
5865 }
5866 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5867 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5868 }
5869 url, err := i.cfg.AuthManager.StartAPIKey(provider)
5870 if err != nil {
5871 i.dialog.ShowResult(false, err.Error())
5872 return
5873 }
5874 i.dialog.ShowWaiting(url)
5875}
5876
5877func (i *Interactive) startOAuthFlow(provider string) {
5878 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5879 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5880 }
5881 // Device-code providers already support headless login and must only
5882 // start one polling flow.
5883 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5884 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5885 if err != nil {
5886 i.dialog.ShowResult(false, err.Error())
5887 return
5888 }
5889 i.dialog.ShowWaiting(loginURL)
5890 return
5891 }
5892 // Always run the manual/copy-code flow in parallel with the local
5893 // callback server so headless environments (docker, SSH) can paste
5894 // the authorization code directly without first pressing 'p'.
5895 _, err := i.cfg.AuthManager.StartOAuth(provider)
5896 if err != nil {
5897 i.dialog.ShowResult(false, err.Error())
5898 return
5899 }
5900 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
5901 if mErr == nil {
5902 i.dialog.ShowWaiting(manualURL)
5903 } else {
5904 i.dialog.ShowResult(false, mErr.Error())
5905 }
5906}
5907
5908func (i *Interactive) startManualOAuthFlow(provider string) {
5909 if i.cfg.AuthManager == nil {
5910 return
5911 }
5912 i.cfg.AuthManager.CancelOAuth()
5913 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
5914 if err != nil {
5915 i.dialog.ShowResult(false, err.Error())
5916 return
5917 }
5918 i.dialog.url = url
5919 i.invalidate()
5920}
5921
5922func (i *Interactive) submitManualOAuthCode(code string) {
5923 if i.cfg.AuthManager == nil {
5924 return
5925 }
5926 go func() {
5927 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
5928 i.dialog.ShowResult(false, err.Error())
5929 i.invalidate()
5930 }
5931 }()
5932}
5933
5934// applyModelSelection switches the active model (and provider, if the
5935// new model belongs to a different one). It rebuilds the underlying
5936// client when needed so the provider wire-protocol matches.
5937// cancelAndWaitForIdle cancels the active turn (if any) and blocks
5938// briefly until the turn goroutine has updated i.busy = false. Used
5939// before destructive slash commands so transcript-mutating work
5940// (/clear, /compact, /logout, /login completion, cross-provider
5941// /model swap) doesn't race with the still-running stream.
5942//
5943// The wait is bounded; if the turn doesn't release within the timeout
5944// we proceed anyway. Worst case is a brief overlap that the agent's
5945// own mutex protects against.
5946func (i *Interactive) cancelAndWaitForIdle() {
5947 i.mu.Lock()
5948 busy := i.busy
5949 cancel := i.cancelTurn
5950 i.mu.Unlock()
5951 if !busy {
5952 return
5953 }
5954 if cancel != nil {
5955 cancel()
5956 }
5957 // ConfirmToolCall waits on its response channel rather than the turn
5958 // context, so cancellation must also resolve every pending prompt.
5959 i.confirmDialog.CancelAll("turn cancelled")
5960 deadline := time.Now().Add(2 * time.Second)
5961 for time.Now().Before(deadline) {
5962 i.mu.Lock()
5963 done := !i.busy
5964 i.mu.Unlock()
5965 if done {
5966 return
5967 }
5968 time.Sleep(10 * time.Millisecond)
5969 }
5970}
5971
5972// openBtwDialog opens the side-chat overlay with a frozen snapshot
5973// of the current main session. The optional argument is auto-
5974// submitted as the first question, so '/btw does X work?' fires the
5975// model call immediately instead of just opening an empty dialog.
5976func (i *Interactive) openBtwDialog(args []string) {
5977 i.agentMu.Lock()
5978 defer i.agentMu.Unlock()
5979 i.mu.Lock()
5980 ag := i.agent
5981 system := ""
5982 if ag != nil {
5983 system, _ = ag.PromptConfig()
5984 }
5985 theme := i.cfg.Theme
5986 model := i.cfg.Model
5987 cwd := i.cfg.CWD
5988 compact := i.compactModeEnabled()
5989 flatTools := i.cfg.FlatTools
5990 lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
5991 i.mu.Unlock()
5992 if ag == nil {
5993 i.mu.Lock()
5994 i.statusErr = "not logged in. type /login first."
5995 i.mu.Unlock()
5996 return
5997 }
5998 seed := strings.TrimSpace(strings.Join(args, " "))
5999 i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6000 i.invalidate()
6001}
6002
6003// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
6004// or queues it behind the active turn using the normal text-only queue.
6005func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6006 i.mu.Lock()
6007 if i.agent == nil {
6008 i.statusErr = "not logged in. type /login first."
6009 i.statusOK = ""
6010 i.mu.Unlock()
6011 i.invalidate()
6012 return
6013 }
6014 if i.busy {
6015 // Keep the mutex held while enqueueing so the turn-completion
6016 // goroutine cannot publish idle and inspect an empty queue between
6017 // this check and the append.
6018 if i.compacting {
6019 i.queued = append(i.queued, prompt)
6020 } else {
6021 i.agent.QueueMessage(prompt)
6022 }
6023 i.mu.Unlock()
6024 i.invalidate()
6025 return
6026 }
6027 i.mu.Unlock()
6028 i.startTurn(ctx, prompt)
6029}
6030
6031// openSkillsDialog opens the skill inspector. The picker reflects
6032// whatever SkillSnapshot returns at call time, so edits to a
6033// SKILL.md made during a session show up on the next /skills.
6034func (i *Interactive) openSkillsDialog() {
6035 var list []*skills.Skill
6036 if i.cfg.SkillSnapshot != nil {
6037 list = i.cfg.SkillSnapshot()
6038 }
6039 i.skillsDialog.Open(list)
6040 i.invalidate()
6041}
6042
6043// openJumpDialog builds a /jump picker from the current transcript.
6044// If the user typed "/jump foo" with a filter and it matches exactly
6045// one turn, jump there directly without showing the dialog.
6046func (i *Interactive) openJumpDialog(args []string) {
6047 if i.view == nil || len(i.view.Messages) == 0 {
6048 i.mu.Lock()
6049 i.statusErr = "nothing to jump to \u2014 the session is empty"
6050 i.mu.Unlock()
6051 return
6052 }
6053 filter := strings.TrimSpace(strings.Join(args, " "))
6054 i.jumpDialog.Open(i.view.Messages, filter)
6055 // Shortcut: with a filter argument that matches exactly one turn,
6056 // jump immediately and skip the picker.
6057 if filter != "" {
6058 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6059 t := tgts[0]
6060 i.jumpDialog.Close()
6061 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6062 }
6063 }
6064}
6065
6066// applyJumpSelection scrolls the chat viewport so the user message at
6067// msgIdx is visible at (or near) the top of the chat area. Uses the
6068// anchor slice returned by view.BuildWithAnchors so the mapping from
6069// message index to row is exact, regardless of variable-height tool
6070// blocks above the target.
6071func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6072 cols := i.lastCols()
6073 chat, anchors := i.view.BuildWithAnchors(cols)
6074 var row int
6075 found := false
6076 for _, a := range anchors {
6077 if a.MessageIdx == msgIdx {
6078 row = a.Row
6079 found = true
6080 break
6081 }
6082 }
6083 if !found {
6084 i.mu.Lock()
6085 i.statusErr = "could not resolve jump target"
6086 i.mu.Unlock()
6087 return
6088 }
6089
6090 chatLen := len(chat)
6091 page := i.chatPage()
6092 if page < 1 {
6093 page = 1
6094 }
6095 // scrollOffset is measured from the bottom of the chat slice, so
6096 // to place `row` at the top of the viewport we want:
6097 // chatLen - scrollOffset - page == row
6098 // Solve for scrollOffset and clamp to [0, chatLen-page].
6099 offset := chatLen - (row + page)
6100 if offset < 0 {
6101 offset = 0
6102 }
6103 maxOffset := chatLen - page
6104 if maxOffset < 0 {
6105 maxOffset = 0
6106 }
6107 if offset > maxOffset {
6108 offset = maxOffset
6109 }
6110
6111 i.mu.Lock()
6112 i.scrollOffset = offset
6113 i.parkedTurn = turnNo
6114 i.parkedTotal = totalTurnsLocked(i.view.Messages)
6115 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6116 i.statusErr = ""
6117 i.mu.Unlock()
6118}
6119
6120// totalTurnsLocked counts user messages in the transcript. Caller is
6121// assumed to hold i.mu (the name is a mild reminder; this function
6122// itself doesn't touch shared state beyond the slice it's handed).
6123func totalTurnsLocked(msgs []provider.Message) int {
6124 n := 0
6125 for _, m := range msgs {
6126 if m.Role == provider.RoleUser {
6127 n++
6128 }
6129 }
6130 return n
6131}
6132
6133// applySessionSelection loads the given session via the cli-provided
6134// callback and snaps the viewport to the bottom (the latest message)
6135// so the user lands at the live tail of the resumed conversation.
6136func (i *Interactive) applySessionSelection(path string) {
6137 if i.cfg.LoadSession == nil {
6138 i.mu.Lock()
6139 i.statusErr = "session loading is not wired in this build"
6140 i.mu.Unlock()
6141 return
6142 }
6143 i.mu.Lock()
6144 if i.sessionLoading {
6145 i.statusErr = "already resuming a session"
6146 i.mu.Unlock()
6147 i.invalidate()
6148 return
6149 }
6150 i.sessionLoading = true
6151 i.statusOK = "resuming session: " + path
6152 i.statusErr = ""
6153 i.mu.Unlock()
6154 i.invalidate()
6155 i.markSessionTitleSwitching()
6156
6157 go func() {
6158 err := i.cfg.LoadSession(path)
6159 if err != nil {
6160 i.restoreFailedSessionTitle()
6161 i.mu.Lock()
6162 i.sessionLoading = false
6163 i.statusErr = err.Error()
6164 i.statusOK = ""
6165 i.mu.Unlock()
6166 i.invalidate()
6167 return
6168 }
6169 i.restoreLoadedSessionTitle()
6170 i.mu.Lock()
6171 i.sessionLoading = false
6172 i.statusOK = "resumed session: " + path
6173 i.statusErr = ""
6174 i.parkedTurn = 0
6175 i.parkedTotal = 0
6176 i.scrollOffset = 0
6177 // Fresh transcript swapped in: drop the auto-follow baseline so
6178 // the next render's follow guard doesn't see the wholesale
6179 // length change as a delta and jump the viewport.
6180 i.prevChatLen = 0
6181 i.prevChatCols = 0
6182 i.extNotes = nil
6183 i.view.InvalidateRenderCache()
6184 if i.agent != nil {
6185 i.view.Messages = i.agent.Messages()
6186 i.cumUsage = i.agent.Cost()
6187 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6188 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6189 } else {
6190 i.lastCtxInput = 0
6191 }
6192 // Snap to the tail again — the swap brought in a fresh
6193 // transcript whose markdown / chroma cost we don't want
6194 // blocking the redraw.
6195 if len(i.view.Messages) > initialResumeTailLimit {
6196 i.view.TailLimit = initialResumeTailLimit
6197 } else {
6198 i.view.TailLimit = 0
6199 }
6200 }
6201 i.mu.Unlock()
6202 i.invalidate()
6203 }()
6204}
6205
6206func (i *Interactive) applyModelSelection(prov, model string) {
6207 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6208}
6209
6210// applyRescueModelSelection is like applyModelSelection but routes
6211// through BuildAgentForRescue so launch-time --api-key / --base-url
6212// overrides are dropped before the new agent is built. Falls back to
6213// the regular builder when the host doesn't wire a rescue builder.
6214func (i *Interactive) applyRescueModelSelection(prov, model string) {
6215 builder := i.cfg.BuildAgentForRescue
6216 if builder == nil {
6217 builder = i.cfg.BuildAgentFor
6218 }
6219 i.swapModel(prov, model, builder, true)
6220}
6221
6222// swapModel applies a /model selection (or a rescue selection) using
6223// the supplied builder. rescue=true tags the success message so the
6224// user can see that launch-time overrides were ignored.
6225func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6226 if i.cfg.SessionTransition != nil {
6227 i.cfg.SessionTransition(func() {
6228 i.swapModelUnserialized(prov, model, builder, rescue)
6229 })
6230 return
6231 }
6232 i.swapModelUnserialized(prov, model, builder, rescue)
6233}
6234
6235func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6236 if model == "" {
6237 return
6238 }
6239 m, err := provider.FindModel(prov, model)
6240 if err != nil {
6241 i.mu.Lock()
6242 i.statusErr = err.Error()
6243 i.mu.Unlock()
6244 return
6245 }
6246 // Same provider AND not a rescue retry: just swap the model on
6247 // the existing agent. Mixed-API providers dispatch from model metadata,
6248 // so the client remains reusable. Rescue retries always rebuild so a stale
6249 // auth header / base URL can't carry over.
6250 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6251 i.mu.Lock()
6252 i.cfg.Model = m.ID
6253 i.agent.Model = m.ID
6254 i.statusOK = "model: " + m.ID
6255 i.statusErr = ""
6256 i.mu.Unlock()
6257 if i.cfg.PersistModel != nil {
6258 i.cfg.PersistModel(i.cfg.Provider, m.ID)
6259 }
6260 return
6261 }
6262 if builder == nil {
6263 i.mu.Lock()
6264 i.statusErr = "cannot switch provider: no builder configured"
6265 i.mu.Unlock()
6266 return
6267 }
6268 // Snapshot the current transcript and cumulative usage BEFORE we
6269 // build the replacement agent so we can hand them off. Without
6270 // this the user perceives the entire session as wiped on a
6271 // cross-provider /model swap.
6272 var carryMsgs []provider.Message
6273 var carryCost provider.Usage
6274 if i.agent != nil {
6275 carryMsgs = i.agent.Messages()
6276 carryCost = i.agent.Cost()
6277 }
6278
6279 ag, p, md, err := builder(m.Provider, m.ID)
6280 if err != nil {
6281 i.mu.Lock()
6282 i.statusErr = err.Error()
6283 i.mu.Unlock()
6284 return
6285 }
6286
6287 // Replay the transcript and seed the cost on the freshly-built
6288 // agent. Messages travel cleanly between providers because they
6289 // use the same provider.Message shape; tool-call ids are local
6290 // to a turn so cross-provider continuation never confuses the
6291 // new model (it just sees the assistant's reply, no orphan
6292 // tool_use blocks because /model swaps are gated to idle state).
6293 if len(carryMsgs) > 0 {
6294 ag.SetMessages(carryMsgs)
6295 }
6296 ag.SeedCost(carryCost)
6297
6298 if i.agent != nil && i.agent != ag {
6299 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6300 }
6301 i.mu.Lock()
6302 i.agent = ag
6303 i.cfg.Provider = p
6304 i.cfg.Model = md
6305 if rescue {
6306 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6307 } else {
6308 i.statusOK = "switched to " + p + " / " + md
6309 }
6310 i.statusErr = ""
6311 // Render cache keys are width+content based, so the new agent's
6312 // identical messages will reuse the existing entries. Nothing
6313 // to invalidate.
6314 i.mu.Unlock()
6315 // The new agent was built off the base tool registry, so any
6316 // dynamically-registered tools need to be reattached. The apply
6317 // helpers are no-ops when their feature is inactive, so the
6318 // cross-provider path still works on a vanilla setup.
6319 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6320 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6321 if i.cfg.PersistModel != nil {
6322 i.cfg.PersistModel(p, md)
6323 }
6324}
6325
6326func (i *Interactive) handleAuthEvent(ev auth.Event) {
6327 switch ev.Kind {
6328 case "started":
6329 i.dialog.ShowWaiting(ev.URL)
6330 case "browser_open":
6331 // no-op
6332 case "error":
6333 i.dialog.ShowResult(false, ev.Message)
6334 case "success":
6335 // Rebuild the agent with the fresh credential.
6336 ag, prov, model, err := i.cfg.BuildAgent()
6337 if err != nil {
6338 i.dialog.ShowResult(false, err.Error())
6339 return
6340 }
6341 commitLogin := func() {
6342 i.mu.Lock()
6343 oldAgent := i.agent
6344 i.agent = ag
6345 i.cfg.Provider = prov
6346 i.cfg.Model = model
6347 i.statusErr = ""
6348 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6349 i.mu.Unlock()
6350 if oldAgent != nil {
6351 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6352 }
6353 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6354 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6355 // Authentication can change the provider used by the live
6356 // agent. Persist it on the active session just like /model.
6357 if i.cfg.PersistModel != nil {
6358 i.cfg.PersistModel(prov, model)
6359 }
6360 }
6361 if i.cfg.SessionTransition != nil {
6362 i.cfg.SessionTransition(commitLogin)
6363 } else {
6364 commitLogin()
6365 }
6366 i.dialog.ShowResult(true, "")
6367 }
6368}
6369
6370// clearPendingCompactTurnLocked drops work that only makes sense after the
6371// current compaction. The caller must hold i.mu.
6372func (i *Interactive) clearPendingCompactTurnLocked() {
6373 i.pendingCompactPrompt = ""
6374 i.pendingCompactImages = nil
6375 i.hasPendingCompactPrompt = false
6376 i.continueAfterCompact = false
6377 i.pendingPostCompactNote = ""
6378}
6379
6380const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`
6381
6382// startAutoCompactContinuation adds an internal user turn that tells the
6383// model to resume the task after threshold compaction. Provider requests
6384// cannot continue from an assistant tail on every supported API, so the
6385// continuation must be represented as a user message. It is hidden from
6386// the rendered transcript but remains in the persisted/model context.
6387func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6388 i.mu.Lock()
6389 ag := i.agent
6390 i.mu.Unlock()
6391 if ag == nil {
6392 i.mu.Lock()
6393 i.busy = false
6394 pendingIdleWork := i.takePendingIdleWorkLocked()
6395 i.mu.Unlock()
6396 runPendingIdleWork(pendingIdleWork)
6397 i.invalidate()
6398 return
6399 }
6400 if ag.QueuedMessageCount() == 0 {
6401 ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
6402 autoCompactContinueMetaKey: "true",
6403 })
6404 }
6405 i.startTurnRequest(parent, "", nil, true, false)
6406}
6407
6408// runCompact invokes core.Agent.Compact and reflects the progress in
6409// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
6410// cancel via the same cancelTurn channel used for normal turns.
6411//
6412// When auto is true the spinner message is pinned to "condensing
6413// history" and the status bar surfaces "(auto)" next to the context
6414// percentage so it's obvious the system triggered this, not the user.
6415func (i *Interactive) runCompact(parent context.Context, auto bool) {
6416 if i.agent == nil {
6417 i.mu.Lock()
6418 i.statusErr = "not logged in. type /login first."
6419 i.mu.Unlock()
6420 return
6421 }
6422 ctx, cancel := context.WithCancel(parent)
6423 i.mu.Lock()
6424 i.busy = true
6425 i.compacting = true
6426 i.spin.Start()
6427 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6428 if auto {
6429 i.activity.kind = activityCondensingHistory
6430 i.autoCompacting = true
6431 } else {
6432 i.activity.kind = activityCompactingHistory
6433 }
6434 i.cancelTurn = cancel
6435 i.statusErr = ""
6436 i.statusOK = ""
6437 // Do NOT set streamOn: the summary text should not be visible
6438 // in the chat while compacting. The user just sees the spinner
6439 // and can keep typing / queue prompts.
6440 i.scrollOffset = 0
6441 i.helpBlock = nil
6442 i.mu.Unlock()
6443 i.invalidate()
6444
6445 go func() {
6446 // Sink discards deltas — we don't stream the summary to the UI.
6447 sink := func(delta string) {}
6448 msgsBefore := i.agent.Messages()
6449 autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
6450 // Keep the usual recent tail when possible, but never let it cover
6451 // the whole transcript. A short session can still be at 70–90% of
6452 // a model's window because one prompt or tool result is large; the
6453 // automatic path must summarize that session instead of failing
6454 // before it reaches the compaction provider.
6455 keepTail := 4
6456 if n := len(msgsBefore); n > 0 && keepTail >= n {
6457 keepTail = n - 1
6458 }
6459 summary, err := i.agent.Compact(ctx, keepTail, sink)
6460 _ = summary
6461 i.mu.Lock()
6462 // Keep busy/compacting asserted while cleanup and queue selection run.
6463 // Completion updates can arrive in this window; clearing busy before
6464 // inspecting the queues lets them race a new turn or be stranded.
6465 i.resetStreamingStateLocked()
6466 i.cancelTurn = nil
6467 i.autoCompacting = false
6468 pendingIdleWork := i.takePendingIdleWorkLocked()
6469
6470 // Drain pending work after the transcript is clean. Overflow recovery
6471 // continues the user message already in the transcript. Pre-turn
6472 // compaction preserves its complete text-and-images request. Regular
6473 // prompts typed during compaction remain in the host queue.
6474 var next string
6475 var nextImages []provider.ImageBlock
6476 var hasNext bool
6477 var continueExisting bool
6478 var continueAutomatically bool
6479
6480 switch {
6481 case err != nil && ctx.Err() != nil:
6482 i.statusErr = ""
6483 if auto {
6484 i.statusOK = "auto-condense cancelled"
6485 } else {
6486 i.statusOK = "compaction cancelled"
6487 }
6488 i.queued = nil // drop queue on cancel
6489 i.clearPendingCompactTurnLocked()
6490 if i.agent != nil {
6491 i.agent.DrainQueuedMessages()
6492 }
6493 case err != nil:
6494 i.statusErr = "compaction failed: " + err.Error()
6495 i.statusOK = ""
6496 i.queued = nil // drop queue on error
6497 i.clearPendingCompactTurnLocked()
6498 if i.agent != nil {
6499 i.agent.DrainQueuedMessages()
6500 }
6501 default:
6502 i.statusErr = ""
6503 // Read token count from the compaction message meta.
6504 tokens := ""
6505 msgs := i.agent.Messages()
6506 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
6507 tokens = msgs[0].Meta["tokens_before"]
6508 }
6509 switch {
6510 case i.pendingPostCompactNote != "":
6511 i.statusOK = i.pendingPostCompactNote
6512 case tokens != "":
6513 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
6514 default:
6515 i.statusOK = "compacted (ctrl+o to expand)"
6516 }
6517 i.pendingPostCompactNote = ""
6518 i.extNotes = stripAutoCompactNotes(i.extNotes)
6519 i.lastCtxInput = 0
6520 i.toolCalls = map[string]*tui.ToolCallView{}
6521 i.toolOrder = nil
6522 i.toolGate = map[string]int{}
6523 i.resetTranscriptRenderLocked()
6524 switch {
6525 case i.continueAfterCompact:
6526 continueExisting = true
6527 i.continueAfterCompact = false
6528 case i.hasPendingCompactPrompt:
6529 next = i.pendingCompactPrompt
6530 nextImages = i.pendingCompactImages
6531 i.pendingCompactPrompt = ""
6532 i.pendingCompactImages = nil
6533 i.hasPendingCompactPrompt = false
6534 hasNext = true
6535 case len(i.queued) > 0:
6536 next, i.queued = i.queued[0], i.queued[1:]
6537 hasNext = true
6538 case autoContinue:
6539 // A successful threshold compaction must hand control back to
6540 // an unfinished model turn. Completed text answers should settle
6541 // without an invented extra request; the user can still prompt
6542 // again normally.
6543 continueAutomatically = true
6544 }
6545 }
6546 // Keep the host busy until the hand-off decision is committed under
6547 // the mutex. A completion update arriving after the compaction result
6548 // but before this assignment is then queued for the selected follow-up
6549 // instead of starting a competing turn.
6550 i.busy = hasNext || continueExisting || continueAutomatically
6551 i.compacting = false
6552 i.autoCompacting = false
6553 i.mu.Unlock()
6554 runPendingIdleWork(pendingIdleWork)
6555 i.invalidate()
6556
6557 if hasNext || continueExisting || continueAutomatically {
6558 p := i.runCtx
6559 if p == nil {
6560 p = context.Background()
6561 }
6562 switch {
6563 case continueExisting:
6564 i.startTurnRequest(p, "", nil, true, true)
6565 case hasNext:
6566 i.startTurnWithImages(p, next, nextImages)
6567 case continueAutomatically:
6568 i.startAutoCompactContinuation(p)
6569 }
6570 }
6571 }()
6572}
6573
6574// shellEscapeCommand reports whether text is a "!command" shell
6575// escape and, if so, returns the command with the leading '!' (and
6576// surrounding whitespace) stripped. A bare "!" with no command is
6577// treated as not an escape so it falls through to the normal prompt
6578// path rather than running an empty shell.
6579func shellEscapeCommand(text string) (string, bool) {
6580 return ShellEscapeCommand(text)
6581}
6582
6583// ShellEscapeCommand reports whether text is a "!command" shell
6584// escape and, if so, returns the command with the leading '!' (and
6585// surrounding whitespace) stripped. A bare "!" with no command is
6586// treated as not an escape so it falls through to the normal prompt
6587// path rather than running an empty shell.
6588func ShellEscapeCommand(text string) (string, bool) {
6589 trimmed := strings.TrimLeft(text, " \t")
6590 if !strings.HasPrefix(trimmed, "!") {
6591 return "", false
6592 }
6593 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
6594 if cmd == "" {
6595 return "", false
6596 }
6597 return cmd, true
6598}
6599
6600// startShellEscape runs a "!command" in the same shell the bash tool
6601// uses, in the session working directory, honoring the /jail sandbox.
6602// It shares the busy/cancel state with the agent: esc cancels it, and
6603// it refuses to start while a turn or another shell escape is already
6604// in flight. Its terminal-log output is appended to the transcript as
6605// user context without automatically starting a model turn.
6606func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6607 i.mu.Lock()
6608 if i.busy || i.shellRunning {
6609 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
6610 i.statusOK = ""
6611 i.mu.Unlock()
6612 i.invalidate()
6613 return
6614 }
6615 if parent == nil {
6616 parent = i.runCtx
6617 }
6618 if parent == nil {
6619 parent = context.Background()
6620 }
6621 ctx, cancel := context.WithCancel(parent)
6622 i.busy = true
6623 i.shellRunning = true
6624 i.shellLive = "$ " + cmd + "\n\n"
6625 i.cancelTurn = cancel
6626 i.statusErr = ""
6627 i.statusOK = ""
6628 i.spin.Start()
6629 i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
6630 // Clear stale extension notes the same way a new turn would so the
6631 // screen doesn't accumulate transient state.
6632 i.extNotes = nil
6633 i.scrollOffset = 0
6634 i.parkedTurn = 0
6635 i.parkedTotal = 0
6636 i.helpBlock = nil
6637 sandbox := i.cfg.Sandbox
6638 cwd := i.cfg.CWD
6639 i.mu.Unlock()
6640 i.invalidate()
6641
6642 go func() {
6643 defer cancel()
6644 raw, _ := json.Marshal(map[string]any{"command": cmd})
6645 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
6646 progress := func(chunk string) {
6647 i.mu.Lock()
6648 i.shellLive += chunk
6649 i.mu.Unlock()
6650 i.invalidate()
6651 }
6652 res, err := bash.Execute(ctx, raw, progress)
6653
6654 var out string
6655 if err != nil {
6656 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
6657 } else {
6658 for _, c := range res.Content {
6659 if tb, ok := c.(provider.TextBlock); ok {
6660 out += tb.Text
6661 }
6662 }
6663 }
6664 cancelled := ctx.Err() != nil
6665 failed := err != nil || res.IsError || cancelled
6666 if cancelled {
6667 out += "\n\n[cancelled]"
6668 }
6669
6670 if i.agent != nil {
6671 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
6672 }
6673
6674 i.mu.Lock()
6675 i.shellRunning = false
6676 i.shellLive = ""
6677 i.busy = false
6678 i.cancelTurn = nil
6679 pendingIdleWork := i.takePendingIdleWorkLocked()
6680 awaitingPre := i.awaitingStartupPre
6681 if failed {
6682 if cancelled {
6683 i.statusErr = "shell command cancelled"
6684 } else {
6685 i.statusErr = "shell command failed"
6686 }
6687 i.statusOK = ""
6688 } else {
6689 i.statusOK = "shell command finished"
6690 i.statusErr = ""
6691 }
6692 i.mu.Unlock()
6693 runPendingIdleWork(pendingIdleWork)
6694 i.invalidate()
6695 if awaitingPre {
6696 i.completeStartupPre()
6697 }
6698 }()
6699}
6700
6701func (i *Interactive) startTurn(parent context.Context, prompt string) {
6702 i.startTurnWithImages(parent, prompt, nil)
6703}
6704
6705func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
6706 i.startTurnRequest(parent, prompt, images, false, false)
6707}
6708
6709// startTurnRequest starts a new prompt or continues an existing user turn.
6710// continueExisting selects Agent.Continue so callers can retry a transcript
6711// message without appending it twice. overflowRecoveryAttempted suppresses
6712// the pre-turn threshold guard and the one-shot overflow recovery path; a
6713// normal post-compaction continuation leaves it false so a still-too-large
6714// rebuilt context can recover once more.
6715func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
6716 if i.agent == nil {
6717 // Text startup pre cannot run without credentials; continue so
6718 // deferred InitialInput (pre-fill or auto-submit) still applies.
6719 i.mu.Lock()
6720 awaitingPre := i.awaitingStartupPre
6721 i.mu.Unlock()
6722 if awaitingPre {
6723 i.completeStartupPre()
6724 }
6725 return
6726 }
6727 // Pre-turn safety: if the most recent context measurement is
6728 // already past the auto-compact threshold, condense before
6729 // sending so the next outbound request stays under the limit.
6730 // The condense flow re-fires the user's queued prompt for us, so
6731 // we just hand it off and exit.
6732 i.mu.Lock()
6733 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
6734 if needsPreCompact {
6735 // Reserve the compaction hand-off before releasing i.mu. A completion
6736 // update arriving in the small gap before runCompact acquires the
6737 // mutex must join the host queue rather than starting a competing turn.
6738 i.busy = true
6739 i.compacting = true
6740 i.autoCompacting = true
6741 if continueExisting {
6742 i.continueAfterCompact = true
6743 } else {
6744 i.pendingCompactPrompt = prompt
6745 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6746 i.hasPendingCompactPrompt = true
6747 }
6748 i.statusErr = ""
6749 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
6750 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
6751 i.mu.Unlock()
6752 i.invalidate()
6753 i.runCompact(parent, true)
6754 return
6755 }
6756 i.mu.Unlock()
6757
6758 ctx, cancel := context.WithCancel(parent)
6759 i.mu.Lock()
6760 i.busy = true
6761 i.spin.Start()
6762 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6763 i.cancelTurn = cancel
6764 i.statusErr = ""
6765 i.statusOK = ""
6766 i.streaming.Reset()
6767 i.streamOn = true
6768 i.pendingAlert = nil
6769 i.toolCalls = map[string]*tui.ToolCallView{}
6770 i.toolOrder = nil
6771 i.toolGate = map[string]int{}
6772 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
6773 i.scrollOffset = 0 // jump back to the bottom on new turn
6774 // Lift the resume tail cap once the user starts interacting. The
6775 // cap is purely a first-paint optimization (don't markdown the
6776 // whole history before showing anything). Keeping it active during
6777 // a turn makes the rendered chat a sliding window: appended
6778 // messages push older ones off the TOP of the buffer, which the
6779 // renderer must treat as a change above the viewport and repaint
6780 // fully, snapping the terminal's native scrollback to the bottom on
6781 // every streamed chunk. A fresh session has no cap (append-only),
6782 // which is why the jump only shows up in resumed sessions. Dropping
6783 // the cap here makes resumed turns append-only too.
6784 i.view.TailLimit = 0
6785 // Reset the auto-follow baseline so the very next render at
6786 // interactive.go:1053 doesn't see a synthetic shrink between
6787 // "last frame had the previous turn's tool overlay" and
6788 // "this frame had it cleared above". Without this, the guard
6789 // reads delta = -(rows in cleared overlay) and decrements
6790 // scrollOffset, which on terminals that mirror zut's pane
6791 // scroll into the host scrollbar visibly yanks the viewport.
6792 // See autofollow_shrink_test.go for the exact arithmetic.
6793 i.prevChatLen = 0
6794 i.prevChatCols = 0
6795 i.parkedTurn = 0 // starting a turn clears the /jump parked state
6796 i.parkedTotal = 0
6797 i.helpBlock = nil // hide the help block once the user asks something
6798 i.mu.Unlock()
6799 i.invalidate()
6800
6801 var (
6802 lastStop provider.StopReason
6803 lastTurnErr error
6804 )
6805 sink := func(ev core.AgentEvent) {
6806 if e, ok := ev.(core.EvTurnEnd); ok {
6807 lastStop = e.Stop
6808 lastTurnErr = e.Err
6809 }
6810 i.handleEvent(ev)
6811 i.invalidate()
6812 }
6813
6814 go func() {
6815 var err error
6816 if continueExisting {
6817 err = i.agent.Continue(ctx, sink)
6818 } else {
6819 err = i.agent.Prompt(ctx, prompt, images, sink)
6820 }
6821 i.mu.Lock()
6822 // Keep busy asserted through final cleanup and queue selection. A
6823 // watcher may enqueue a completion summary concurrently with the
6824 // provider's final event; publishing idle before inspecting queues can
6825 // strand that summary in the core agent queue or start an overlapping
6826 // turn.
6827 // Don't touch streamPending / streamFlushPending here — the
6828 // pacer may still be draining the final deltas and needs to
6829 // paint them even though Prompt has returned. It will reset
6830 // streamOn on its own once the buffer empties.
6831 if len(i.streamPending) == 0 {
6832 i.streamOn = false
6833 }
6834 i.cancelTurn = nil
6835 pendingIdleWork := i.takePendingIdleWorkLocked()
6836 if err != nil && ctx.Err() == nil {
6837 i.statusErr = err.Error()
6838 }
6839 // Decide whether to offer a model rescue picker for recoverable
6840 // provider failures (auth/rate/temporary). The picker opens after
6841 // the mutex is released so it can take its own locks freely.
6842 var (
6843 offer bool
6844 rescueWhy string
6845 rescueImgs []provider.ImageBlock
6846 rescueModel string
6847 rescueProv string
6848 rescueFprov string
6849 )
6850 if err != nil && ctx.Err() == nil {
6851 if ok, reason := classifyRescueError(err); ok {
6852 offer = true
6853 rescueWhy = reason
6854 rescueImgs = images
6855 rescueModel = i.cfg.Model
6856 rescueProv = i.cfg.Provider
6857 rescueFprov = extractFailedProvider(err)
6858 if rescueFprov == "" {
6859 rescueFprov = i.cfg.Provider
6860 }
6861 // Suppress the red banner — the rescue dialog already
6862 // surfaces the failure.
6863 i.statusErr = ""
6864 }
6865 }
6866 // Detect responses that reject the current context, either as an
6867 // HTTP 413 payload limit or a model context-window error. Token-
6868 // based auto-compact can miss both when metadata is stale or the
6869 // limit is measured in raw bytes. Compact once, then continue the
6870 // user message already present in the transcript.
6871 contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6872 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6873 if recoverContextOverflow {
6874 i.statusErr = ""
6875 i.continueAfterCompact = true
6876 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
6877 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
6878 }
6879 // Persist the assistant's reply (and every tool row before
6880 // it) to the session file while the turn memory is hot.
6881 // Without this, WriteNewTranscript only fires at zut exit,
6882 // meaning a crash or ungraceful kill drops the whole
6883 // conversation. FlushSession is idempotent (it advances the
6884 // baseline so subsequent flushes only write new rows).
6885 flush := i.cfg.FlushSession
6886 i.mu.Unlock()
6887 runPendingIdleWork(pendingIdleWork)
6888 if flush != nil {
6889 flush()
6890 }
6891 i.mu.Lock()
6892 awaitingPre := i.awaitingStartupPre
6893 // Pop the next queued message, if any, and relaunch.
6894 var next string
6895 var hasNext bool
6896 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
6897 next, i.queued = i.queued[0], i.queued[1:]
6898 hasNext = true
6899 }
6900 // If the turn was cancelled or errored, drop the queue so the
6901 // user isn't bombarded with stale messages after an interrupt.
6902 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
6903 i.queued = nil
6904 if i.agent != nil {
6905 i.agent.DrainQueuedMessages()
6906 }
6907 }
6908 // Decide whether the next thing to do is an auto-compaction.
6909 // Only fires when the turn completed cleanly AND no host-side
6910 // or agent-side queued messages are waiting (otherwise a queued
6911 // message would race the condense).
6912 agentQueued := 0
6913 if i.agent != nil {
6914 agentQueued = i.agent.QueuedMessageCount()
6915 }
6916 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
6917 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
6918 // The agent run can finish before the paced final text reaches the
6919 // transcript. A compaction replaces that transcript, so it must never
6920 // race the still-live stream frame; otherwise stale deltas can repaint
6921 // after the replacement and corrupt the scrollback renderer's model.
6922 if recoverContextOverflow || shouldAutoCompact {
6923 i.resetStreamingStateLocked()
6924 }
6925 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
6926 i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
6927 i.mu.Unlock()
6928 if alertReason != "" {
6929 i.scheduleMainAlert(alertReason)
6930 }
6931 i.invalidate()
6932 parent := i.runCtx
6933 if parent == nil {
6934 parent = context.Background()
6935 }
6936 if awaitingPre {
6937 i.completeStartupPre()
6938 return
6939 }
6940 switch {
6941 case hasNext:
6942 i.startTurn(parent, next)
6943 case continueQueued:
6944 i.startTurnRequest(parent, "", nil, true, false)
6945 case offer:
6946 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
6947 case recoverContextOverflow:
6948 i.runCompact(parent, true)
6949 case shouldAutoCompact:
6950 i.runCompact(parent, true)
6951 }
6952 }()
6953}
6954
6955func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
6956 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
6957 return ""
6958 }
6959 if rescue {
6960 return "rescue_required"
6961 }
6962 if stop == provider.StopAborted {
6963 return ""
6964 }
6965 if err != nil || turnErr != nil || stop == provider.StopError {
6966 return "agent_error"
6967 }
6968 if stop == provider.StopLength {
6969 return "response_truncated"
6970 }
6971 return "agent_done"
6972}
6973
6974// openRescueDialog surfaces the rescue model picker after a recoverable
6975// provider failure. The pending prompt + images are stashed on the
6976// Interactive so a later applyRescueSelection can re-run the same turn
6977// against the freshly-picked model. activeProvider/failedProvider are
6978// usually the same, but some clients embed different prefixes in their
6979// errors than the configured provider id, so we accept both.
6980func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
6981 if i.rescueDialog == nil {
6982 return
6983 }
6984 loggedIn := []string{}
6985 if i.cfg.LoggedInProviders != nil {
6986 loggedIn = i.cfg.LoggedInProviders()
6987 }
6988 fprov := failedProvider
6989 if fprov == "" {
6990 fprov = activeProvider
6991 }
6992 i.mu.Lock()
6993 i.pendingRescuePrompt = prompt
6994 i.pendingRescueImages = images
6995 i.mu.Unlock()
6996 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
6997 i.invalidate()
6998}
6999
7000// applyRescueSelection switches model (cross-provider if needed) and
7001// re-runs the same prompt+images that just failed. Mirrors
7002// applyModelSelection's transcript-carry logic so the user keeps full
7003// session continuity across the swap.
7004func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
7005 if model == "" {
7006 return
7007 }
7008 i.applyRescueModelSelection(prov, model)
7009 i.mu.Lock()
7010 images := i.pendingRescueImages
7011 if prompt == "" {
7012 prompt = i.pendingRescuePrompt
7013 }
7014 i.pendingRescuePrompt = ""
7015 i.pendingRescueImages = nil
7016 i.mu.Unlock()
7017 parent := i.runCtx
7018 if parent == nil {
7019 parent = context.Background()
7020 }
7021 i.startTurnWithImages(parent, prompt, images)
7022}
7023
7024func stripAutoCompactNotes(notes []string) []string {
7025 if len(notes) == 0 {
7026 return notes
7027 }
7028 out := notes[:0]
7029 for _, n := range notes {
7030 if strings.Contains(n, "condensing history") {
7031 continue
7032 }
7033 out = append(out, n)
7034 }
7035 return out
7036}
7037
7038// autoCompactNoteLine returns a styled chat-area note for the
7039// inline auto-compact heads-up. Lives in extNotes so it survives
7040// the busy-spinner overwrite of the status row.
7041func autoCompactNoteLine(th tui.Theme, msg string) string {
7042 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7043}
7044
7045// isContextOverflowError matches provider responses that reject the
7046// current request because its raw payload or tokenized input is too
7047// large. Providers surface this as either HTTP 413 or semantic context
7048// errors with differing codes and messages.
7049func isContextOverflowError(err error) bool {
7050 if err == nil {
7051 return false
7052 }
7053 msg := strings.ToLower(err.Error())
7054 return strings.Contains(msg, "http 413") ||
7055 strings.Contains(msg, " 413") ||
7056 strings.HasPrefix(msg, "413 ") ||
7057 strings.Contains(msg, "payload too large") ||
7058 strings.Contains(msg, "request entity too large") ||
7059 strings.Contains(msg, "input exceeds the context window") ||
7060 strings.Contains(msg, "context window exceeded") ||
7061 strings.Contains(msg, "maximum context length") ||
7062 strings.Contains(msg, "context_length_exceeded") ||
7063 (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7064}
7065
7066const defaultAutoCompactThreshold = 85
7067
7068func normalizeAutoCompactThreshold(threshold *int) int {
7069 if threshold == nil {
7070 return defaultAutoCompactThreshold
7071 }
7072 switch *threshold {
7073 case 0, 70, 80, 85, 90:
7074 return *threshold
7075 default:
7076 return defaultAutoCompactThreshold
7077 }
7078}
7079
7080func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7081 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
7082 return false
7083 }
7084 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
7085}
7086
7087// shouldAutoContinueAfterCompaction reports whether the transcript tail looks
7088// unfinished. A complete assistant text answer should not cause an extra
7089// model turn merely because its context was compacted; tool calls, reasoning-
7090// only replies, and non-assistant tails still need the model to resume.
7091func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7092 if len(msgs) == 0 {
7093 return false
7094 }
7095 last := msgs[len(msgs)-1]
7096 if last.Role != provider.RoleAssistant {
7097 return true
7098 }
7099 hasText := false
7100 for _, content := range last.Content {
7101 switch block := content.(type) {
7102 case provider.ToolCallBlock:
7103 return true
7104 case provider.TextBlock:
7105 if strings.TrimSpace(block.Text) != "" {
7106 hasText = true
7107 }
7108 case provider.ReasoningBlock:
7109 // Reasoning without a visible answer is not terminal.
7110 default:
7111 return true
7112 }
7113 }
7114 return !hasText
7115}
7116
7117// shouldAutoCompactLocked reports whether the last turn pushed context
7118// usage past the auto-compact threshold. Must be called with i.mu
7119// held; it reads lastCtxInput and the current model's context window.
7120func (i *Interactive) shouldAutoCompactLocked() bool {
7121 if i.agent == nil {
7122 return false
7123 }
7124 if i.autoCompacting {
7125 return false
7126 }
7127 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
7128 if err != nil || m.ContextWindow <= 0 {
7129 return false
7130 }
7131 threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
7132 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
7133}
7134
7135func (i *Interactive) handleEvent(ev core.AgentEvent) {
7136 i.mu.Lock()
7137 defer i.mu.Unlock()
7138 i.activity.apply(ev)
7139 switch e := ev.(type) {
7140 case core.EvAssistantStart:
7141 // Fires at the top of every oneTurn, including follow-up
7142 // turns after tool use. Without this, the streaming buffer
7143 // is still marked off from the previous assistant message
7144 // and the final summary text pops in all at once instead
7145 // of typewriter-streaming delta by delta.
7146 i.streaming.Reset()
7147 i.streamPending = i.streamPending[:0]
7148 i.streamFlushPending = false
7149 i.streamOn = true
7150 // Clear the live tool-call overlay. Any tools from the
7151 // previous round are now fully folded into the transcript
7152 // (assistant tool_use block + tool role message with the
7153 // result), so keeping them in the overlay would duplicate
7154 // them in the view — once inside the finalised transcript
7155 // and once below the streaming block, with the streaming
7156 // summary sandwiched in between. The next EvToolUseStart
7157 // will populate fresh entries for this turn's tools.
7158 i.toolCalls = map[string]*tui.ToolCallView{}
7159 i.toolOrder = nil
7160 i.toolGate = map[string]int{}
7161 case core.EvTextDelta:
7162 // Buffer into streamPending; the paintPace ticker drains
7163 // it into i.streaming a few runes at a time for a smooth
7164 // typewriter effect independent of upstream chunk size.
7165 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
7166 i.streamOn = true
7167 case core.EvAssistantMessage:
7168 // OnAssistant + telegram mirroring always fire on message
7169 // arrival — they read the FINAL message content, which is
7170 // complete regardless of what's still in the pacer.
7171 i.assistantMessageSideEffects(e.Message)
7172 // If the pacer still has characters to drain, keep streamOn
7173 // true and mark flush pending; the paintPace ticker will
7174 // drain the remainder and reset streaming state when done.
7175 // Otherwise (rare: full-replay sessions, abort paths) clear
7176 // synchronously so a later render doesn't show stale text.
7177 if len(i.streamPending) > 0 {
7178 i.streamFlushPending = true
7179 return
7180 }
7181 i.resetStreamingStateLocked()
7182 case core.EvToolUseStart:
7183 // Live streaming: pre-create the view so the user sees the
7184 // tool call being composed in real time. Any subsequent
7185 // EvToolCall for the same ID updates the same struct (the
7186 // final parsed args + name are already known here).
7187 if _, exists := i.toolCalls[e.ID]; !exists {
7188 i.toolCalls[e.ID] = &tui.ToolCallView{
7189 ID: e.ID,
7190 Name: e.Name,
7191 Streaming: true,
7192 }
7193 i.toolOrder = append(i.toolOrder, e.ID)
7194 i.gateToolLocked(e.ID)
7195 }
7196 case core.EvToolUseArgs:
7197 if tc, ok := i.toolCalls[e.ID]; ok {
7198 tc.RawJSONBuf += e.Delta
7199 // Refresh the live path as soon as it parses; used in
7200 // the header (write /Users/example/Desktop/demo.ts)
7201 // while the content is still streaming.
7202 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
7203 tc.LivePath = p
7204 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
7205 tc.LivePath = p
7206 }
7207 }
7208 case core.EvToolUseEnd:
7209 if tc, ok := i.toolCalls[e.ID]; ok {
7210 tc.Streaming = false
7211 }
7212 case core.EvToolCall:
7213 // If we already pre-created the view during streaming, just
7214 // refresh the final Args summary. Otherwise create a new one
7215 // (non-streaming providers or legacy paths).
7216 if tc, ok := i.toolCalls[e.ID]; ok {
7217 tc.Args = tui.ShortArgs(e.Name, e.Args)
7218 if tc.RawJSONBuf == "" {
7219 tc.RawJSONBuf = string(e.Args)
7220 }
7221 tc.Streaming = false
7222 } else {
7223 i.toolCalls[e.ID] = &tui.ToolCallView{
7224 ID: e.ID,
7225 Name: e.Name,
7226 Args: tui.ShortArgs(e.Name, e.Args),
7227 RawJSONBuf: string(e.Args),
7228 }
7229 i.toolOrder = append(i.toolOrder, e.ID)
7230 i.gateToolLocked(e.ID)
7231 }
7232 case core.EvToolResult:
7233 if tc, ok := i.toolCalls[e.ID]; ok {
7234 tc.Done = true
7235 tc.Error = e.Result.IsError
7236 tc.Preview = ""
7237 var text strings.Builder
7238 for _, c := range e.Result.Content {
7239 if tb, ok := c.(provider.TextBlock); ok {
7240 if text.Len() > 0 {
7241 text.WriteString("\n")
7242 }
7243 text.WriteString(tb.Text)
7244 }
7245 }
7246 tc.Result = text.String()
7247 }
7248 if i.cfg.OnToolResult != nil {
7249 i.cfg.OnToolResult(e.ID, e.Result)
7250 }
7251 case core.EvUsage:
7252 i.cumUsage = e.Cumulative
7253 if e.Usage.InputTokens > 0 {
7254 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
7255 }
7256 case core.EvTurnEnd:
7257 if e.Stop == provider.StopAborted {
7258 i.resetStreamingStateLocked()
7259 i.statusErr = ""
7260 i.statusOK = "cancelled"
7261 return
7262 }
7263 if e.Stop == provider.StopLength {
7264 // The model hit its output-token cap mid-response, so the
7265 // reply (often a long write/edit) is truncated. Surface it
7266 // explicitly, otherwise the turn just ends and reads like
7267 // the UI gave up. The agent already requests the model's
7268 // full MaxOutput budget, so this means the response genuinely
7269 // exceeded that ceiling; ask the user to continue.
7270 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
7271 i.statusOK = ""
7272 return
7273 }
7274 // Don't surface mid-loop stream errors as a red banner here.
7275 // EvTurnEnd fires after every step in a multi-step tool loop,
7276 // so a transient 503 / network blip would briefly paint a red
7277 // banner over the still-streaming chat before the agent loop
7278 // either retries or exits. The final error (if any) is set by
7279 // startTurnWithImages once Prompt() returns, and recoverable
7280 // failures are routed to the rescue picker instead — which
7281 // keeps the chat clean while the agent is working.
7282 _ = e.Err
7283 }
7284}
7285
7286// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7287// ag is still the live interactive agent. It serializes the identity check
7288// with agent replacement and swaps both values atomically in core.Agent.
7289func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7290 if ag == nil {
7291 return nil, false
7292 }
7293 // Match the lock order used by dynamic tool mutations so a refresh
7294 // cannot race a snapshot-and-replace operation that would reintroduce
7295 // the old registry after the atomic prompt swap.
7296 i.agentMu.Lock()
7297 defer i.agentMu.Unlock()
7298 i.mu.Lock()
7299 defer i.mu.Unlock()
7300 if i.agent != ag {
7301 return nil, false
7302 }
7303 return ag.SetPromptConfig(system, tools), true
7304}
7305
7306// DeferUntilIdle runs work immediately when the interactive agent is idle,
7307// or after the current turn/compaction/shell operation releases the busy
7308// state. Callbacks run outside i.mu.
7309func (i *Interactive) DeferUntilIdle(fn func()) {
7310 if fn == nil {
7311 return
7312 }
7313 i.mu.Lock()
7314 if i.busy {
7315 i.pendingIdleWork = append(i.pendingIdleWork, fn)
7316 i.mu.Unlock()
7317 return
7318 }
7319 i.mu.Unlock()
7320 fn()
7321}
7322
7323func (i *Interactive) takePendingIdleWorkLocked() []func() {
7324 work := i.pendingIdleWork
7325 i.pendingIdleWork = nil
7326 return work
7327}
7328
7329func runPendingIdleWork(work []func()) {
7330 for _, fn := range work {
7331 if fn != nil {
7332 fn()
7333 }
7334 }
7335}
7336
7337// Agent returns the current agent, if any. Used by cli.go to flush the
7338// final transcript to the session file.
7339func (i *Interactive) Agent() *core.Agent {
7340 i.mu.Lock()
7341 defer i.mu.Unlock()
7342 return i.agent
7343}
7344
7345// silence unused import in some build configs
7346var _ = fmt.Sprintf
7347
7348// runReloadExt triggers a live reload of every extension (discovered
7349// + explicit). Runs on a goroutine so the TUI stays responsive; the
7350// Manager.Reload takes a couple of hundred ms to shut down subprocs
7351// and respawn them. Shows a temporary status line throughout.
7352func (i *Interactive) runReloadExt(ctx context.Context) {
7353 if i.cfg.Extensions == nil {
7354 i.mu.Lock()
7355 i.statusErr = "no extension manager in this build"
7356 i.mu.Unlock()
7357 i.invalidate()
7358 return
7359 }
7360 i.mu.Lock()
7361 i.statusOK = "Reloading extensions..."
7362 i.statusErr = ""
7363 i.mu.Unlock()
7364 i.invalidate()
7365
7366 go func() {
7367 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
7368 msg, failed := formatReloadStatus(stats)
7369 seq := i.setReloadStatus(msg, failed)
7370 i.invalidate()
7371 go i.dismissReloadStatus(ctx, seq, msg, failed)
7372 }()
7373}
7374
7375// Confirm implements core.Confirmer. The agent goroutine calls
7376// this synchronously before every tool invocation when --no-yolo is
7377// active. We push the request onto the confirmDialog queue, trigger
7378// a redraw, and block the caller until the user answers.
7379//
7380// If the session is cancelled or the TUI exits mid-prompt, any
7381// pending request is refused via CancelAll so the agent doesn't
7382// deadlock.
7383func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
7384 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
7385}
7386
7387// ConfirmToolCall attaches a side-effect-free preview to the matching live
7388// tool panel, then blocks until the user approves or refuses the call.
7389func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
7390 resp := make(chan core.ConfirmDecision, 1)
7391 if isBtwOrigin(call.Origin) {
7392 req := &confirmRequest{
7393 toolName: call.Name,
7394 preview: call.Summary,
7395 resp: resp,
7396 returnToChild: true,
7397 }
7398 if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7399 i.confirmDialog.Enqueue(req)
7400 }) {
7401 return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7402 }
7403 i.invalidate()
7404 return <-resp
7405 }
7406 if call.ID != "" {
7407 i.mu.Lock()
7408 if tc, ok := i.toolCalls[call.ID]; ok {
7409 tc.Args = call.Summary
7410 tc.Preview = call.Content
7411 i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
7412 }
7413 i.mu.Unlock()
7414 }
7415 i.confirmDialog.Enqueue(&confirmRequest{
7416 toolName: call.Name,
7417 preview: call.Summary,
7418 resp: resp,
7419 })
7420 i.invalidate()
7421 return <-resp
7422}
7423
7424// openTelegramDialog shows the picker for `/telegram` with no arg.
7425// Items depend on current state: disconnect + status when running,
7426// connect + status when stopped.
7427func (i *Interactive) openTelegramDialog() {
7428 items := i.telegramMenuItems()
7429 if len(items) == 0 {
7430 i.mu.Lock()
7431 i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
7432 i.mu.Unlock()
7433 i.invalidate()
7434 return
7435 }
7436 i.telegramDialog.Open(items)
7437 i.invalidate()
7438}
7439
7440// telegramMenuItems builds the dialog entries for the current
7441// bridge state. Returns empty when no bot.json exists so the
7442// caller can show a helpful status line instead of an empty menu.
7443func (i *Interactive) telegramMenuItems() []telegramItem {
7444 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
7445 if err != nil || cfg.BotToken == "" {
7446 return nil
7447 }
7448 var items []telegramItem
7449 if i.telegramBridge != nil && i.telegramBridge.Active() {
7450 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
7451 st := i.telegramBridge.State()
7452 hint := "active"
7453 if st.Username != "" {
7454 hint += " as @" + st.Username
7455 }
7456 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
7457 } else {
7458 label := "connect"
7459 hint := "start mirroring dms into this session"
7460 if cfg.AllowedUserID == 0 {
7461 hint = "awaiting pairing (send /start to the bot once connected)"
7462 }
7463 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
7464 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
7465 }
7466 return items
7467}
7468
7469// doTelegram dispatches one of the three explicit actions. Called
7470// from /telegram <action> or after the picker selects a row.
7471func (i *Interactive) doTelegram(action string) {
7472 switch action {
7473 case "connect":
7474 i.telegramConnect()
7475 case "disconnect":
7476 i.telegramDisconnect()
7477 case "status":
7478 i.telegramStatus()
7479 default:
7480 i.mu.Lock()
7481 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
7482 i.mu.Unlock()
7483 i.invalidate()
7484 }
7485}
7486
7487// telegramConnect starts the bridge. Refuses if it's already
7488// running or if the on-disk bot.json is missing a token.
7489func (i *Interactive) telegramConnect() {
7490 if i.telegramBridge != nil && i.telegramBridge.Active() {
7491 i.mu.Lock()
7492 i.statusOK = "telegram already connected"
7493 i.statusErr = ""
7494 i.mu.Unlock()
7495 i.invalidate()
7496 return
7497 }
7498 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
7499 if err != nil {
7500 i.mu.Lock()
7501 i.statusErr = "telegram: " + err.Error()
7502 i.mu.Unlock()
7503 i.invalidate()
7504 return
7505 }
7506 if cfg.BotToken == "" {
7507 i.mu.Lock()
7508 i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
7509 i.mu.Unlock()
7510 i.invalidate()
7511 return
7512 }
7513 // Refuse to start when a background daemon is already polling
7514 // the same bot. Two concurrent long-poll consumers race each
7515 // update and one always loses, so DMs get half-delivered. The
7516 // user can `zut telegram-bot stop` first, then /telegram connect.
7517 if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
7518 i.mu.Lock()
7519 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
7520 i.mu.Unlock()
7521 i.invalidate()
7522 return
7523 }
7524 i.telegramBridge = &telegram.Bridge{
7525 Client: telegram.NewClient(cfg.BotToken),
7526 Config: cfg,
7527 Save: func(next telegram.Config) error {
7528 return telegram.SaveConfig(i.cfg.ZutHome, next)
7529 },
7530 Host: &telegramHost{iv: i},
7531 }
7532 if err := i.telegramBridge.Start(i.runCtx); err != nil {
7533 i.telegramBridge = nil
7534 i.mu.Lock()
7535 i.statusErr = "telegram connect failed: " + err.Error()
7536 i.mu.Unlock()
7537 i.invalidate()
7538 return
7539 }
7540 i.applyTelegramTools(true)
7541 state := i.telegramBridge.State()
7542 label := "telegram connected"
7543 if state.Username != "" {
7544 label += " as @" + state.Username
7545 }
7546 if state.PairedID == 0 {
7547 label += " — send /start to the bot from your phone to claim it"
7548 }
7549 i.mu.Lock()
7550 i.statusOK = label
7551 i.statusErr = ""
7552 i.mu.Unlock()
7553 i.invalidate()
7554}
7555
7556// telegramDisconnect stops the bridge. No-op when already stopped.
7557func (i *Interactive) telegramDisconnect() {
7558 if i.telegramBridge == nil || !i.telegramBridge.Active() {
7559 i.mu.Lock()
7560 i.statusOK = "telegram already disconnected"
7561 i.statusErr = ""
7562 i.mu.Unlock()
7563 i.invalidate()
7564 return
7565 }
7566 i.telegramBridge.Stop()
7567 i.applyTelegramTools(false)
7568 i.mu.Lock()
7569 i.statusOK = "telegram disconnected"
7570 i.statusErr = ""
7571 i.mu.Unlock()
7572 i.invalidate()
7573}
7574
7575// telegramSenderAdapter wraps the bridge so the tools package can
7576// drive it without importing telegram directly. The Active() check
7577// is forwarded to the bridge so the tool can fail clearly with a
7578// model-readable error when the user disconnected mid-turn.
7579type telegramSenderAdapter struct {
7580 bridge *telegram.Bridge
7581}
7582
7583func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
7584 if a.bridge == nil {
7585 return fmt.Errorf("telegram bridge is not connected")
7586 }
7587 return a.bridge.SendImage(ctx, path, caption)
7588}
7589
7590func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
7591 if a.bridge == nil {
7592 return fmt.Errorf("telegram bridge is not connected")
7593 }
7594 return a.bridge.SendDocument(ctx, path, caption)
7595}
7596
7597func (a telegramSenderAdapter) Active() bool {
7598 return a.bridge != nil && a.bridge.Active()
7599}
7600
7601// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
7602// delegated turn's outcome separately from the long-lived daemon status.
7603type subagentWatchEntry struct {
7604 agent *subagents.Agent
7605 task string
7606 done bool
7607 outcome string
7608 err string
7609}
7610
7611// TrackSubagentWorker is the exported entry point used by the cli to
7612// hand a freshly-spawned auto-subagents agent off to the watcher.
7613func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
7614 i.trackSubagentWorker(a, task)
7615}
7616
7617// trackSubagentWorker records a freshly-spawned auto-subagents agent and
7618// subscribes to both ways its delegated task can finish. Successful
7619// long-lived agents report a prompt-level turn_end and keep listening;
7620// startup failures and unexpected daemon exits only unblock Agent.Wait.
7621// Whichever signal arrives first completes the entry exactly once.
7622//
7623// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
7624// subagent is enabled, so this is a no-op when the feature is off.
7625func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
7626 if i == nil || a == nil {
7627 return
7628 }
7629 entry := &subagentWatchEntry{agent: a, task: task}
7630 i.subagentWatchMu.Lock()
7631 i.subagentWatch = append(i.subagentWatch, entry)
7632 i.subagentWatchMu.Unlock()
7633
7634 a.SetOnTurnEnd(func(step int, errMsg string) {
7635 outcome := "completed"
7636 if errMsg != "" {
7637 outcome = "failed"
7638 }
7639 i.completeSupervisorWatchEntry(entry, outcome, errMsg)
7640 })
7641 go func() {
7642 a.Wait()
7643 snap := a.Snapshot()
7644 outcome := string(snap.Status)
7645 if snap.Status == subagents.StatusDone {
7646 outcome = "completed"
7647 }
7648 i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
7649 }()
7650}
7651
7652func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
7653 i.subagentWatchMu.Lock()
7654 if entry.done {
7655 i.subagentWatchMu.Unlock()
7656 return
7657 }
7658 entry.done = true
7659 entry.outcome = outcome
7660 entry.err = errMsg
7661 allDone := true
7662 for _, e := range i.subagentWatch {
7663 if !e.done {
7664 allDone = false
7665 break
7666 }
7667 }
7668 var batch []*subagentWatchEntry
7669 if allDone {
7670 batch = i.subagentWatch
7671 i.subagentWatch = nil
7672 }
7673 i.subagentWatchMu.Unlock()
7674 if len(batch) != 0 {
7675 i.flushSupervisorSummary(batch)
7676 }
7677}
7678
7679// flushSupervisorSummary composes a synthetic user turn describing every
7680// sub-agent's outcome and injects it via SubmitOrQueue so the main
7681// agent picks it up at the next safe boundary. The summary is
7682// phrased as a system update ("Auto-subagents finished: ...") so the
7683// model treats it as observed state, not as a fresh user request.
7684func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
7685 if len(batch) == 0 {
7686 return
7687 }
7688 var sb strings.Builder
7689 fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
7690 for idx, e := range batch {
7691 snap := e.agent.Snapshot()
7692 status := e.outcome
7693 if status == "" {
7694 status = string(snap.Status)
7695 }
7696 task := snap.Task
7697 if task == "" {
7698 task = e.task
7699 }
7700 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
7701 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
7702 if snap.Err != "" {
7703 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
7704 } else if e.err != "" {
7705 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
7706 }
7707 if snap.LastAssistant != "" {
7708 sb.WriteString(" final response:\n")
7709 sb.WriteString(snap.LastAssistant)
7710 sb.WriteString("\n")
7711 } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
7712 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
7713 }
7714 sb.WriteString("\n")
7715 }
7716 sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
7717 i.SubmitOrQueue(sb.String(), nil)
7718}
7719
7720func truncateForSummary(s string, n int) string {
7721 s = strings.TrimSpace(s)
7722 if len(s) <= n {
7723 return s
7724 }
7725 return s[:n-3] + "..."
7726}
7727
7728// autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
7729// toggle, in the same order Resolve appends them to a new agent.
7730func autoSubagentsAddenda(cfg InteractiveConfig) []string {
7731 addenda := make([]string, 0, 2)
7732 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
7733 addenda = append(addenda, addendum)
7734 }
7735 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
7736 addenda = append(addenda, addendum)
7737 }
7738 return addenda
7739}
7740
7741func containsAutoSubagentsAddendum(addenda []string, want string) bool {
7742 for _, addendum := range addenda {
7743 if addendum == want {
7744 return true
7745 }
7746 }
7747 return false
7748}
7749
7750// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
7751// have been appended by auto-subagents. Resolve appends owned blocks at the
7752// end, so removing the last occurrence preserves an identical base block.
7753func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
7754 idx := strings.LastIndex(system, addendum)
7755 if idx < 0 {
7756 return system, false
7757 }
7758 return system[:idx] + system[idx+len(addendum):], true
7759}
7760
7761// applyAutoSubagentsSystemPrompt appends (active=true) or strips
7762// (active=false) the auto-subagents prompt blocks on the running agent.
7763// The profile manifest and delegation guidance are managed together so
7764// toggling auto-subagents never leaves the model with names but no tool.
7765func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7766 // i.mu is sufficient here: this path updates the existing agent's system
7767 // prompt and managed addenda in place; it does not replace the agent or
7768 // its tool registry, so agentMu is not required.
7769 i.mu.Lock()
7770 defer i.mu.Unlock()
7771 if i.agent == nil {
7772 return
7773 }
7774 addenda := autoSubagentsAddenda(i.cfg)
7775 if active {
7776 sys, _ := i.agent.PromptConfig()
7777 for _, addendum := range addenda {
7778 if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
7779 continue
7780 }
7781 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
7782 sys += "\n\n"
7783 }
7784 sys += addendum
7785 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
7786 }
7787 i.agent.SetSystemPrompt(sys)
7788 return
7789 }
7790
7791 sys, _ := i.agent.PromptConfig()
7792 changed := false
7793 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
7794 var removed bool
7795 sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
7796 changed = changed || removed
7797 }
7798 i.managedAutoSubagentsAddenda = nil
7799 if changed {
7800 i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
7801 }
7802}
7803
7804// applyAutoSubagentsTool registers (active=true) or removes (active=false)
7805// the auto-subagent tools on the running agent so the model only sees them
7806// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
7807// snapshot+mutate pattern so extension tools and /reload-ext additions
7808// survive a toggle.
7809func (i *Interactive) autoSubagentsAvailable() bool {
7810 return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
7811}
7812
7813func (i *Interactive) autoSubagentsUnavailableHint() string {
7814 var hints []string
7815 if i.cfg.Supervisor == nil {
7816 hints = append(hints, "subagent supervisor not available in this mode")
7817 }
7818 if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
7819 hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
7820 }
7821 return strings.Join(hints, "; ")
7822}
7823
7824func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
7825 return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
7826}
7827
7828func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
7829 if cfg.AutoSubagentsStatusToolAllowed != nil {
7830 return *cfg.AutoSubagentsStatusToolAllowed
7831 }
7832 // Preserve the old single-boolean embedding contract: when callers have
7833 // not supplied the new field, status follows the existing delegation gate.
7834 return autoSubagentsToolAllowedConfig(cfg)
7835}
7836
7837func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
7838 return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
7839}
7840
7841func (i *Interactive) autoSubagentsToolAllowed() bool {
7842 return autoSubagentsToolAllowedConfig(i.cfg)
7843}
7844
7845func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
7846 return autoSubagentsStatusToolAllowedConfig(i.cfg)
7847}
7848
7849func (i *Interactive) autoSubagentsEnabled() bool {
7850 i.mu.Lock()
7851 defer i.mu.Unlock()
7852 return i.autoSubagentsEnabledLocked()
7853}
7854
7855func (i *Interactive) autoSubagentsEnabledLocked() bool {
7856 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
7857}
7858
7859func (i *Interactive) applyAutoSubagentsTool(active bool) {
7860 i.agentMu.Lock()
7861 defer i.agentMu.Unlock()
7862 i.mu.Lock()
7863 defer i.mu.Unlock()
7864 if i.agent == nil {
7865 return
7866 }
7867 current := i.agent.ToolsSnapshot()
7868 next := core.Registry{}
7869 for name, t := range current {
7870 if name == "subagent_spawn" || name == "subagent_status" {
7871 continue
7872 }
7873 next[name] = t
7874 }
7875 if active && i.autoSubagentsAvailable() {
7876 if i.autoSubagentsToolAllowed() {
7877 canonical := &tools.SubagentSpawnTool{
7878 Supervisor: i.cfg.Supervisor,
7879 Enabled: func() bool { return i.autoSubagentsEnabled() },
7880 DefaultModel: func() string { return i.cfg.Model },
7881 DefaultProvider: func() string { return i.cfg.Provider },
7882 DefaultReasoning: func() string { return i.cfg.Reasoning },
7883 ResolveSubagent: i.cfg.ResolveSubagent,
7884 OnSpawned: i.trackSubagentWorker,
7885 }
7886 next[canonical.Name()] = canonical
7887 }
7888 if i.autoSubagentsStatusToolAllowed() {
7889 statusTool := &tools.SubagentStatusTool{
7890 Supervisor: i.cfg.Supervisor,
7891 Enabled: func() bool { return i.autoSubagentsEnabled() },
7892 }
7893 next[statusTool.Name()] = statusTool
7894 }
7895 }
7896 i.agent.SetTools(next)
7897}
7898
7899// applyTelegramTools registers (active=true) or removes (active=false)
7900// the telegram_send_image and telegram_send_file tools on the running
7901// agent so the model only sees them while the bridge is connected.
7902// Snapshots and mutates the live tool registry so any extension or
7903// /reload-ext additions made while Telegram is connected survive a
7904// later /telegram disconnect (we only add or strip the two telegram
7905// entries, never the rest).
7906func (i *Interactive) applyTelegramTools(active bool) {
7907 i.agentMu.Lock()
7908 defer i.agentMu.Unlock()
7909 i.mu.Lock()
7910 defer i.mu.Unlock()
7911 if i.agent == nil {
7912 return
7913 }
7914 current := i.agent.ToolsSnapshot()
7915 next := core.Registry{}
7916 for name, t := range current {
7917 if name == "telegram_send_image" || name == "telegram_send_file" {
7918 continue
7919 }
7920 next[name] = t
7921 }
7922 if active {
7923 sender := telegramSenderAdapter{bridge: i.telegramBridge}
7924 next["telegram_send_image"] = &tools.TelegramSendImageTool{
7925 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
7926 }
7927 next["telegram_send_file"] = &tools.TelegramSendFileTool{
7928 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
7929 }
7930 }
7931 i.agent.SetTools(next)
7932}
7933
7934// telegramStatus writes a one-liner describing the bridge state.
7935// Reports on both the in-tui bridge and the background daemon so
7936// the user isn't confused when the daemon owns the poll loop.
7937func (i *Interactive) telegramStatus() {
7938 var msg string
7939 if i.telegramBridge != nil && i.telegramBridge.Active() {
7940 s := i.telegramBridge.State()
7941 msg = "telegram: connected (tui bridge)"
7942 if s.Username != "" {
7943 msg += " as @" + s.Username
7944 }
7945 if s.PairedID != 0 {
7946 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
7947 } else {
7948 msg += " - awaiting pairing"
7949 }
7950 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
7951 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
7952 } else {
7953 cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
7954 if cfg.BotToken == "" {
7955 msg = "telegram: not configured. run `zut telegram-bot setup` first."
7956 } else {
7957 msg = "telegram: disconnected"
7958 if cfg.BotUsername != "" {
7959 msg += " (@" + cfg.BotUsername + " ready to connect)"
7960 }
7961 }
7962 }
7963 i.mu.Lock()
7964 i.statusOK = msg
7965 i.statusErr = ""
7966 i.mu.Unlock()
7967 i.invalidate()
7968}
7969
7970// telegramHost adapts *Interactive to telegram.Host so the bridge
7971// can call back into the TUI without importing modes directly.
7972type telegramHost struct{ iv *Interactive }
7973
7974func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
7975 h.iv.SubmitOrQueue(prompt, images)
7976}
7977
7978func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
7979
7980func (h *telegramHost) Status() string {
7981 h.iv.mu.Lock()
7982 providerName := h.iv.cfg.Provider
7983 model := h.iv.cfg.Model
7984 cwd := h.iv.cfg.CWD
7985 usage := h.iv.cumUsage
7986 subscription := h.iv.cfg.AuthMethod == "oauth"
7987 ctxUsed := h.iv.lastCtxInput
7988 busy := h.iv.busy
7989 queued := len(h.iv.queued)
7990 h.iv.mu.Unlock()
7991
7992 ctxMax := 0
7993 if m, err := provider.FindModel(providerName, model); err == nil {
7994 ctxMax = m.ContextWindow
7995 }
7996 return telegram.FormatStatus(telegram.StatusSnapshot{
7997 Provider: providerName,
7998 Model: model,
7999 CWD: cwd,
8000 Usage: usage,
8001 Subscription: subscription,
8002 ContextUsed: ctxUsed,
8003 ContextMax: ctxMax,
8004 Busy: busy,
8005 Queued: queued,
8006 })
8007}
8008
8009func (h *telegramHost) Notify(level, message string) {
8010 h.iv.mu.Lock()
8011 switch level {
8012 case "error", "warn":
8013 h.iv.statusErr = message
8014 h.iv.statusOK = ""
8015 default:
8016 h.iv.statusOK = message
8017 h.iv.statusErr = ""
8018 }
8019 h.iv.mu.Unlock()
8020 h.iv.invalidate()
8021}
8022
8023// openSessionOpsDialog shows the picker for `/session` with no arg.
8024// Always offers export, import, fork, tree; the handlers bail with
8025// a clear status message when the precondition isn't met (empty
8026// transcript on fork; no parent/siblings on tree).
8027func (i *Interactive) openSessionOpsDialog() {
8028 items := []sessionOpsItem{
8029 {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
8030 {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
8031 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
8032 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
8033 }
8034 i.sessionOpsDialog.Open(items)
8035 i.invalidate()
8036}
8037
8038// doSessionOp dispatches export, import, fork, or tree. arg is the
8039// optional positional argument from e.g. /session export <path>
8040// or /session import <path>; fork and tree ignore it.
8041func (i *Interactive) doSessionOp(action, arg string) {
8042 switch action {
8043 case "export":
8044 i.doSessionExport(arg)
8045 case "import":
8046 i.doSessionImport(arg)
8047 case "fork":
8048 i.doSessionFork()
8049 case "tree":
8050 i.doSessionTree()
8051 default:
8052 i.mu.Lock()
8053 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
8054 i.mu.Unlock()
8055 i.invalidate()
8056 }
8057}
8058
8059// doSessionExport writes the live session file to destination path
8060// dst. When dst is empty we default to ~/Downloads (falling back to
8061// the user's home directory if it doesn't exist). The helper
8062// expands a leading `~` and creates any missing parent directories.
8063func (i *Interactive) doSessionExport(dst string) {
8064 src := i.currentSessionPath()
8065 if src == "" {
8066 i.mu.Lock()
8067 i.statusErr = "export: no session is active (running with --no-session?)"
8068 i.mu.Unlock()
8069 i.invalidate()
8070 return
8071 }
8072 // Persist any in-memory agent messages to the session file so
8073 // the export carries the full conversation. Without this, the
8074 // default lazy-flush-at-exit strategy leaves most of a running
8075 // session unwritten and the export ends up with only the meta.
8076 if i.cfg.FlushSession != nil {
8077 i.cfg.FlushSession()
8078 }
8079 dst = unquotePath(dst)
8080 if dst == "" {
8081 dst = defaultExportDir()
8082 } else {
8083 dst = expandTilde(dst)
8084 }
8085 out, err := core.ExportSession(src, dst)
8086 if err != nil {
8087 i.mu.Lock()
8088 i.statusErr = "export: " + err.Error()
8089 i.mu.Unlock()
8090 i.invalidate()
8091 return
8092 }
8093 i.mu.Lock()
8094 i.statusOK = "exported session to " + friendlyPath(out)
8095 i.statusErr = ""
8096 i.mu.Unlock()
8097 i.invalidate()
8098}
8099
8100// doSessionImport copies the .zutsession file at src into the
8101// running cwd's sessions directory and loads it as the active
8102// session, same as `/sessions` -> pick. When src is empty we ask
8103// the user to pass a path (no usable default here).
8104func (i *Interactive) doSessionImport(src string) {
8105 src = unquotePath(src)
8106 if src == "" {
8107 i.mu.Lock()
8108 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
8109 i.mu.Unlock()
8110 i.invalidate()
8111 return
8112 }
8113 src = expandTilde(src)
8114 if _, err := os.Stat(src); err != nil {
8115 i.mu.Lock()
8116 i.statusErr = "import: " + err.Error()
8117 i.mu.Unlock()
8118 i.invalidate()
8119 return
8120 }
8121 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
8122 if err != nil {
8123 i.mu.Lock()
8124 i.statusErr = "import: " + err.Error()
8125 i.mu.Unlock()
8126 i.invalidate()
8127 return
8128 }
8129 if i.cfg.LoadSession == nil {
8130 i.mu.Lock()
8131 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
8132 i.statusErr = ""
8133 i.mu.Unlock()
8134 i.invalidate()
8135 return
8136 }
8137 i.markSessionTitleSwitching()
8138 if err := i.cfg.LoadSession(newPath); err != nil {
8139 i.restoreFailedSessionTitle()
8140 i.mu.Lock()
8141 i.statusErr = "import: load failed: " + err.Error()
8142 i.mu.Unlock()
8143 i.invalidate()
8144 return
8145 }
8146 i.restoreLoadedSessionTitle()
8147 i.mu.Lock()
8148 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
8149 i.statusErr = ""
8150 i.mu.Unlock()
8151 i.invalidate()
8152}
8153
8154// defaultExportDir returns ~/Downloads when it exists, or ~ as a
8155// fallback, or /tmp on exotic machines with no home dir.
8156func defaultExportDir() string {
8157 home, err := os.UserHomeDir()
8158 if err != nil || home == "" {
8159 return os.TempDir()
8160 }
8161 downloads := filepath.Join(home, "Downloads")
8162 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
8163 return downloads
8164 }
8165 return home
8166}
8167
8168// expandTilde turns a leading ~ into the user's home directory.
8169// Returns the input unchanged when there's no tilde or no home.
8170func expandTilde(p string) string {
8171 if p == "" || p[0] != '~' {
8172 return p
8173 }
8174 home, err := os.UserHomeDir()
8175 if err != nil || home == "" {
8176 return p
8177 }
8178 if len(p) == 1 {
8179 return home
8180 }
8181 if p[1] == '/' || p[1] == filepath.Separator {
8182 return filepath.Join(home, p[2:])
8183 }
8184 return p
8185}
8186
8187// unquotePath strips a matching pair of surrounding single or
8188// double quotes. Drag-drop paste in the tui auto-quotes dropped
8189// file paths so the shell-like `/session import 'foo bar.zs'`
8190// stays well-formed; when the TUI's own slash handler consumes
8191// the arg, we want the raw path back.
8192func unquotePath(p string) string {
8193 p = strings.TrimSpace(p)
8194 if len(p) >= 2 {
8195 first := p[0]
8196 last := p[len(p)-1]
8197 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
8198 p = p[1 : len(p)-1]
8199 }
8200 }
8201 return p
8202}
8203
8204// friendlyPath collapses the user's home directory to a leading ~
8205// so status messages read cleanly. Falls back to the raw path when
8206// the home dir is unknown.
8207func friendlyPath(p string) string {
8208 home, err := os.UserHomeDir()
8209 if err != nil || home == "" {
8210 return p
8211 }
8212 if strings.HasPrefix(p, home+string(filepath.Separator)) {
8213 return "~" + p[len(home):]
8214 }
8215 return p
8216}
8217
8218// doSessionFork opens the /jump turn picker in "fork mode". The
8219// next selection branches the current session at that user turn
8220// instead of scrolling the viewport to it.
8221func (i *Interactive) doSessionFork() {
8222 path := i.currentSessionPath()
8223 if path == "" {
8224 i.mu.Lock()
8225 i.statusErr = "fork: no session is active (running with --no-session?)"
8226 i.mu.Unlock()
8227 i.invalidate()
8228 return
8229 }
8230 i.mu.Lock()
8231 busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
8232 queued := len(i.queued) != 0
8233 ag := i.agent
8234 i.mu.Unlock()
8235 if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
8236 i.mu.Lock()
8237 i.statusErr = "fork: wait until the current turn and queued messages finish"
8238 i.mu.Unlock()
8239 i.invalidate()
8240 return
8241 }
8242 if i.cfg.FlushSession != nil {
8243 i.cfg.FlushSession()
8244 }
8245 snapshot, err := core.ReadSessionSnapshot(path)
8246 if err != nil {
8247 i.mu.Lock()
8248 i.statusErr = "fork: " + err.Error()
8249 i.mu.Unlock()
8250 i.invalidate()
8251 return
8252 }
8253 msgs := snapshot.Messages
8254 if len(msgs) == 0 {
8255 i.mu.Lock()
8256 i.statusErr = "fork: transcript is empty; nothing to fork from"
8257 i.mu.Unlock()
8258 i.invalidate()
8259 return
8260 }
8261 i.pendingFork = true
8262 i.jumpDialog.Open(msgs, "")
8263 i.invalidate()
8264}
8265
8266// doSessionTree opens the current session family after the shared
8267// fail-closed gate succeeds. It intentionally does not fall back to the
8268// in-memory transcript: a tree that cannot be persisted and reloaded cannot
8269// safely create a navigation branch.
8270func (i *Interactive) doSessionTree() {
8271 i.openSessionTree()
8272}
8273
8274// applySessionTreeMessageSelection keeps the old scalar integration
8275// source-compatible for package consumers. New dialog selections use the
8276// structured sessionTreeTarget path below.
8277func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
8278 target := sessionTreeTarget{
8279 SourcePath: src,
8280 EffectiveIndex: msgIdx,
8281 SelectionBoundary: msgIdx + 1,
8282 Role: role,
8283 UserDraft: prompt,
8284 Boundary: sessionTreeMessageBoundary,
8285 }
8286 if role == provider.RoleUser {
8287 target.SelectionBoundary = msgIdx
8288 }
8289 i.applySessionTreeTarget(target, turnNo)
8290}
8291
8292// applySessionTreeTarget checks out a structured dialog target. The target's
8293// source and effective indices come from the same preflight snapshot the
8294// dialog rendered, while the live read below preserves compatibility with
8295// the existing LoadSession(path) error callback.
8296func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
8297 // Selection is a state-changing operation too. Use the selection gate so
8298 // a queued/busy turn cannot race the hidden branch or accidentally submit
8299 // a provider turn while the session is being swapped. The open gate cannot
8300 // be reused here because the tree dialog itself owns the keyboard.
8301 if !i.canCommitSessionTreeSelection() {
8302 i.setSessionTreeError("tree: session branching is not available in this build")
8303 return
8304 }
8305 src := target.SourcePath
8306 if src == "" {
8307 src = i.currentSessionPath()
8308 }
8309 if src == "" {
8310 i.setSessionTreeError("tree: no session is active")
8311 return
8312 }
8313
8314 // Read and validate the selected row before flushing or creating anything.
8315 // This gives tool-call/result pairs an atomic boundary and prevents a
8316 // malformed selection from changing the active session. Historical rows
8317 // come from a pre-compaction segment; current rows use the effective
8318 // snapshot that the running agent resumes from.
8319 var msgs []provider.Message
8320 var historicalSegment core.SessionHistorySegment
8321 if target.Historical {
8322 history, err := core.ReadSessionHistory(src)
8323 if err != nil {
8324 i.setSessionTreeError("tree: read selection: " + err.Error())
8325 return
8326 }
8327 if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
8328 i.setSessionTreeError("tree: selected history segment is unavailable")
8329 return
8330 }
8331 historicalSegment = history.Segments[target.HistorySegment]
8332 msgs = historicalSegment.Messages
8333 } else {
8334 sess, current, err := core.OpenSession(src)
8335 if err != nil {
8336 i.setSessionTreeError("tree: read selection: " + err.Error())
8337 return
8338 }
8339 _ = sess.Close()
8340 msgs = current
8341 }
8342 selection, err := sessionTreeSelection(msgs, target)
8343 if err != nil {
8344 i.setSessionTreeError("tree: " + err.Error())
8345 return
8346 }
8347 if i.cfg.FlushSession != nil {
8348 i.cfg.FlushSession()
8349 }
8350 var newPath string
8351 if target.Historical {
8352 newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
8353 } else {
8354 newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
8355 }
8356 if err != nil {
8357 i.setSessionTreeError("tree: " + err.Error())
8358 return
8359 }
8360 i.markSessionTitleSwitching()
8361 if err := i.cfg.LoadSession(newPath); err != nil {
8362 i.restoreFailedSessionTitle()
8363 i.setSessionTreeError("tree: checkout failed: " + err.Error())
8364 return
8365 }
8366
8367 // LoadSession is intentionally the old func(string) error callback: the
8368 // CLI and embedders already own agent/session swapping. Refresh the view
8369 // from the swapped agent here instead of requiring a new callback result.
8370 i.resetSessionTitleForFreshBranch()
8371 i.scrollToBottom()
8372 i.mu.Lock()
8373 i.lastCtxInput = 0
8374 if i.agent != nil {
8375 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
8376 i.cumUsage = i.agent.Cost()
8377 last := i.agent.LastTurnUsage()
8378 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
8379 i.view.InvalidateRenderCache()
8380 }
8381 i.toolCalls = map[string]*tui.ToolCallView{}
8382 i.toolOrder = nil
8383 i.toolGate = map[string]int{}
8384 i.extNotes = nil
8385 if selection.restoreDraft {
8386 i.ed.SetValue(selection.draftText)
8387 i.clipboardImages = selection.images
8388 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
8389 } else {
8390 i.ed.Clear()
8391 i.clipboardImages = nil
8392 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
8393 }
8394 i.statusErr = ""
8395 i.mu.Unlock()
8396 i.scrollToBottom()
8397 // Selection only changes local/session state. In particular, do not call
8398 // Submit, startTurn, or queue a message here; the restored user draft is
8399 // submitted only by a later explicit Enter.
8400 i.invalidate()
8401}
8402
8403// applyForkSelection branches the current session at msgIdx+1 (so
8404// the selected user message and everything before it is included
8405// in the new branch), then switches the running agent to the new
8406// file. Called from the jump-dialog handler when pendingFork=true.
8407func (i *Interactive) applyForkSelection(msgIdx int) {
8408 i.pendingFork = false
8409 src := i.currentSessionPath()
8410 if src == "" {
8411 i.mu.Lock()
8412 i.statusErr = "fork: no session is active"
8413 i.mu.Unlock()
8414 i.invalidate()
8415 return
8416 }
8417 if i.cfg.FlushSession != nil {
8418 i.cfg.FlushSession()
8419 }
8420 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
8421 // the selected user message is included.
8422 upTo := msgIdx + 1
8423 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
8424 if err != nil {
8425 i.mu.Lock()
8426 i.statusErr = "fork: " + err.Error()
8427 i.mu.Unlock()
8428 i.invalidate()
8429 return
8430 }
8431 if i.cfg.LoadSession == nil {
8432 i.mu.Lock()
8433 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
8434 i.statusErr = ""
8435 i.mu.Unlock()
8436 i.invalidate()
8437 return
8438 }
8439 i.markSessionTitleSwitching()
8440 if err := i.cfg.LoadSession(newPath); err != nil {
8441 i.restoreFailedSessionTitle()
8442 i.mu.Lock()
8443 i.statusErr = "fork: switch failed: " + err.Error()
8444 i.mu.Unlock()
8445 i.invalidate()
8446 return
8447 }
8448 i.resetSessionTitleForFreshBranch()
8449 i.mu.Lock()
8450 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
8451 i.statusErr = ""
8452 i.mu.Unlock()
8453 i.invalidate()
8454}
8455
8456// formatInt is a tiny strconv.Itoa shim; keeps the handler above
8457// from needing a strconv import just for one call.
8458func formatInt(n int) string {
8459 return fmt.Sprintf("%d", n)
8460}
8461
8462// assistantText returns the concatenated text of every TextBlock in
8463// m. Used by the streaming-view dedupe guard to tell when a live
8464// streamed reply has already been promoted into the transcript.
8465func assistantText(m provider.Message) string {
8466 var sb strings.Builder
8467 for _, c := range m.Content {
8468 if tb, ok := c.(provider.TextBlock); ok {
8469 if sb.Len() > 0 {
8470 sb.WriteByte('\n')
8471 }
8472 sb.WriteString(tb.Text)
8473 }
8474 }
8475 return sb.String()
8476}
8477
8478// resetTranscriptRenderLocked invalidates every render cache that assumes
8479// the previous transcript remains structurally intact. Compaction is a
8480// replacement, not an append: the flow renderer must repaint from the new
8481// transcript rather than diff it against scrollback rows from the old one.
8482// Must be called with i.mu held.
8483func (i *Interactive) resetTranscriptRenderLocked() {
8484 i.view.InvalidateRenderCache()
8485 i.chatCacheValid = false
8486 i.prevChatLen = 0
8487 i.prevChatCols = 0
8488 i.prevChatRows = 0
8489 i.prevScrollOffset = 0
8490 if i.rend != nil {
8491 i.rend.Invalidate()
8492 }
8493}
8494
8495// resetStreamingStateLocked clears every piece of streaming state
8496// in one shot. Used by abort paths (turn cancel, compact hand-off,
8497// queue drain) so the pacer doesn't keep draining stale runes from
8498// a prior turn. Must be called with i.mu held.
8499func (i *Interactive) resetStreamingStateLocked() {
8500 i.streaming.Reset()
8501 i.streamPending = i.streamPending[:0]
8502 i.streamFlushPending = false
8503 i.streamOn = false
8504 i.openAllToolGatesLocked()
8505}
8506
8507// openAllToolGatesLocked drops every pending tool gate so that any
8508// tool registered during this turn renders unconditionally from now
8509// on. Called when streaming finalizes (the paced text has fully
8510// drained and `streaming` is about to reset to length 0): without
8511// this, the gate comparison against a freshly-reset streaming buffer
8512// would wrongly re-hide tools that had already cleared their gate.
8513// Must be called with i.mu held.
8514func (i *Interactive) openAllToolGatesLocked() {
8515 for id := range i.toolGate {
8516 i.toolGate[id] = 0
8517 }
8518}
8519
8520// gateToolLocked records the stream position at which a tool call may
8521// become visible. The gate is the total length the streaming buffer
8522// will reach once the pacer has drained everything currently queued
8523// (already painted + still pending). Holding the tool block back
8524// until the pacer crosses that mark guarantees the prose emitted
8525// before the tool call finishes typing out above it, instead of the
8526// tool block snapping in while the paragraph is still filling in.
8527//
8528// We only gate while text is actively streaming. If no stream is in
8529// flight (gate 0), the tool shows immediately, which is the correct
8530// behaviour for tool-only turns and replayed sessions. First
8531// registration wins so a later EvToolCall can't move an existing
8532// gate. Must be called with i.mu held.
8533func (i *Interactive) gateToolLocked(id string) {
8534 if _, ok := i.toolGate[id]; ok {
8535 return
8536 }
8537 if !i.streamOn {
8538 i.toolGate[id] = 0
8539 return
8540 }
8541 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
8542}
8543
8544// toolGateOpenLocked reports whether the gated tool block may render
8545// yet, i.e. the pacer has drained enough text to reach the position
8546// recorded when the tool call arrived. Must be called with i.mu held.
8547func (i *Interactive) toolGateOpenLocked(id string) bool {
8548 gate, ok := i.toolGate[id]
8549 if !ok || gate == 0 {
8550 return true
8551 }
8552 return i.streaming.Len() >= gate
8553}
8554
8555// assistantMessageSideEffects runs the non-visual hooks attached to
8556// EvAssistantMessage: the host-provided OnAssistant callback and the
8557// telegram-bridge mirror. Called with i.mu held.
8558//
8559// Factored out of handleEvent because the streaming pacer may defer
8560// visual reset until after the last buffered rune has painted, but
8561// the callbacks themselves must fire on message arrival so
8562// downstream observers (session persistence, telegram, cost panels)
8563// don't wait on a UI animation to catch up.
8564func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
8565 if i.cfg.OnAssistant != nil {
8566 i.cfg.OnAssistant(m)
8567 }
8568 if i.telegramBridge != nil && i.telegramBridge.Active() {
8569 var sb strings.Builder
8570 for _, c := range m.Content {
8571 if tb, ok := c.(provider.TextBlock); ok {
8572 if sb.Len() > 0 {
8573 sb.WriteString("\n")
8574 }
8575 sb.WriteString(tb.Text)
8576 }
8577 }
8578 if text := sb.String(); strings.TrimSpace(text) != "" {
8579 go i.telegramBridge.OnAssistantText(text)
8580 }
8581 }
8582}
8583
8584// paintPaceRate is how many runes the streaming pacer releases per
8585// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
8586// that a 500-rune summary finishes in ~1.3s, slow enough to look
8587// like a human typing. Empirically matches the feel of provider
8588// paths that already drip-stream natively.
8589const paintPaceRate = 6
8590
8591// paintPaceInterval is the tick interval for the streaming pacer.
8592// 16ms lines up with the redraw throttle so we never paint faster
8593// than the terminal can keep up.
8594const paintPaceInterval = 16 * time.Millisecond
8595
8596// runStreamPacer drains buffered deltas from streamPending into
8597// streaming a small batch per tick, invalidating after each move.
8598// It stops when the context cancels (tui shutdown).
8599//
8600// Why a pacer: providers differ wildly in how they chunk their
8601// text_delta events. The API-key path on Anthropic emits ~30 drips
8602// for a 400-token summary; the OAuth path can coalesce the same
8603// summary into 3 fat chunks, visually indistinguishable from "the
8604// whole reply just appeared". The pacer normalizes that so every
8605// path looks the same on screen.
8606func (i *Interactive) runStreamPacer(ctx context.Context) {
8607 t := time.NewTicker(paintPaceInterval)
8608 defer t.Stop()
8609 for {
8610 select {
8611 case <-ctx.Done():
8612 return
8613 case <-t.C:
8614 i.mu.Lock()
8615 if len(i.streamPending) == 0 {
8616 // EvAssistantMessage already fired but the pacer
8617 // was still draining a tick ago. Everything is now
8618 // painted; clear the streaming flags so the next
8619 // redraw shows the finalised transcript message
8620 // and hides the streaming overlay.
8621 if i.streamFlushPending {
8622 i.streamFlushPending = false
8623 i.streaming.Reset()
8624 i.streamOn = false
8625 i.openAllToolGatesLocked()
8626 i.mu.Unlock()
8627 i.invalidate()
8628 continue
8629 }
8630 i.mu.Unlock()
8631 continue
8632 }
8633 n := paintPaceRate
8634 if n > len(i.streamPending) {
8635 n = len(i.streamPending)
8636 }
8637 i.streaming.WriteString(string(i.streamPending[:n]))
8638 i.streamPending = i.streamPending[n:]
8639 i.mu.Unlock()
8640 i.invalidate()
8641 }
8642 }
8643}