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