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