v0.1.49 1a49277

interactive.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/patriceckhart/zot/internal/agent/extensions"
13	"github.com/patriceckhart/zot/internal/agent/modes/telegram"
14	"github.com/patriceckhart/zot/internal/agent/tools"
15	"github.com/patriceckhart/zot/internal/auth"
16	"github.com/patriceckhart/zot/internal/core"
17	"github.com/patriceckhart/zot/internal/extproto"
18	"github.com/patriceckhart/zot/internal/provider"
19	"github.com/patriceckhart/zot/internal/skills"
20	"github.com/patriceckhart/zot/internal/tui"
21)
22
23// InteractiveConfig configures the interactive loop.
24type InteractiveConfig struct {
25	Terminal     tui.Terminal
26	Theme        tui.Theme
27	Model        string
28	Provider     string
29	AuthMethod   string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
30	BaseURL      string
31	Reasoning    string
32	SystemPrompt string
33	Tools        core.Registry
34	MaxSteps     int
35	CWD          string
36
37	// Agent is optional. If nil, zot opens without credentials; the
38	// user must /login before they can prompt.
39	Agent *core.Agent
40
41	InitialInput string
42
43	// Auth is required. When the user runs /login, Interactive talks to
44	// AuthManager to open a browser and wait for the callback.
45	AuthManager *auth.Manager
46	// BuildAgent is called after a successful login to (re)construct the
47	// agent with the fresh credential. It returns the new agent and
48	// the concrete provider/model in use.
49	BuildAgent func() (*core.Agent, string, string, error)
50
51	// BuildAgentFor rebuilds the agent with an explicit provider/model
52	// override (used by the /model picker when switching providers).
53	// If providerOverride is empty, the current provider is kept.
54	BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
55
56	// LoggedInProviders returns the list of provider names that
57	// currently have credentials. Used by /model to filter the
58	// picker to only show reachable models.
59	LoggedInProviders func() []string
60
61	// ZotHome is the root directory for sessions/, used by /sessions
62	// and the update-check cache.
63	ZotHome string
64
65	// Version is the binary's current version (from main.version).
66	// Used only for display; the update check itself is done outside
67	// this package to avoid an import cycle.
68	Version string
69
70	// UpdateInfoChan is an optional channel that delivers the result
71	// of the github-release update check. Interactive reads at most
72	// one value, drops it if the check reported nothing, and otherwise
73	// surfaces a yellow "update available" banner at the top of the
74	// chat. Nil channel = no banner, no startup cost.
75	UpdateInfoChan <-chan UpdateInfo
76
77	// Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
78	Sandbox *tools.Sandbox
79
80	// LoadSession swaps the current session for the one at path. The
81	// callback returns the new agent message slice so the TUI can invalidate.
82	LoadSession func(path string) error
83
84	// CurrentSessionPath returns the path of the live session file
85	// on disk (the one every AppendMessage writes to). Used by
86	// /session export so the exporter ships the exact bytes on
87	// disk. Returns an empty string when --no-session is set or
88	// no session is open.
89	CurrentSessionPath func() string
90
91	// FlushSession writes any in-memory agent messages to the
92	// session file that haven't been persisted yet. Called by
93	// /session export right before reading the file so the
94	// exported bytes reflect the full current conversation, not
95	// just the rows the agent happened to write synchronously.
96	// The default WriteNewTranscript-at-exit strategy means most
97	// of a running session lives only in memory until the tui
98	// closes; without a flush hook, /session export writes a
99	// file that only has the meta row.
100	FlushSession func()
101
102	// PersistModel is called whenever the user switches model or provider.
103	// It should update config.json and (if there's an active session)
104	// write a new meta row so resume picks up the same model.
105	PersistModel func(providerName, model string)
106
107	OnAssistant  func(m provider.Message)
108	OnToolResult func(id string, r core.ToolResult)
109
110	// Extensions, if non-nil, lets users invoke extension-registered
111	// slash commands. Commands declared by extensions are looked up
112	// AFTER the built-in catalog so a built-in name always wins.
113	Extensions *extensions.Manager
114
115	// SkillSnapshot, if non-nil, returns the current list of
116	// discovered SKILL.md files. Re-invoked each time /skills opens
117	// so the picker reflects edits made during the session.
118	SkillSnapshot func() []*skills.Skill
119
120	// ChangelogChan, if non-nil, delivers release-notes for the
121	// current binary version once at startup. Interactive opens a
122	// dismissible overlay when the channel produces a non-empty
123	// body. Receiver fires at most once.
124	ChangelogChan <-chan ChangelogPayload
125
126	// OnChangelogDismiss, if non-nil, is called once the user
127	// closes the changelog overlay. The cli wires this to a
128	// MarkChangelogShown call so the same version doesn't show
129	// again on the next launch.
130	OnChangelogDismiss func()
131
132	// NoYolo is true when --no-yolo was passed. Interactive opens
133	// a confirmation dialog before every tool call and blocks the
134	// tool until the user picks yes / always-this-tool /
135	// always-all / no. When false (default), tools run freely.
136	NoYolo bool
137
138	// ConfirmGate is the session-scoped gate wrapping this
139	// interactive's Confirmer. When non-nil, /yolo can call
140	// AllowAll() on it to disable confirmation for the rest of the
141	// session. When nil (yolo mode), /yolo reports that there's
142	// nothing to disable.
143	ConfirmGate *core.ConfirmGate
144}
145
146// ChangelogPayload mirrors agent.ChangelogInfo without the import
147// cycle. The cli builds one from the http response, the tui opens
148// the overlay when one arrives.
149type ChangelogPayload struct {
150	Version string
151	Body    string
152	URL     string
153}
154
155// Interactive is the TUI chat loop.
156type chatCacheKey struct {
157	cols            int
158	agentRev        uint64
159	statusOK        string
160	statusErr       string
161	help            string
162	extNotes        string
163	updateAvailable bool
164	updateCurrent   string
165	updateLatest    string
166	updateURL       string
167	welcomeShowVer  bool
168	expandAll       bool
169}
170
171type Interactive struct {
172	cfg  InteractiveConfig
173	view *tui.View
174	ed   *tui.Editor
175	rend *tui.Renderer
176
177	mu        sync.Mutex
178	agent     *core.Agent
179	streaming strings.Builder // what's currently painted on screen
180	streamOn  bool
181
182	// streamPending is the runes buffered after each EvTextDelta that
183	// haven't yet been promoted into `streaming` for rendering. It
184	// exists because some provider paths (notably Anthropic via the
185	// oauth/subscription channel) coalesce the model's output into a
186	// few fat chunks instead of drip-streaming. Painting those fat
187	// chunks verbatim looks like the summary "just appears". The
188	// paintPace goroutine drains a handful of runes per tick from
189	// this buffer into `streaming`, giving every path the same
190	// typewriter feel regardless of upstream chunk size.
191	streamPending []rune
192	// streamFlushPending is set when EvAssistantMessage fires while
193	// streamPending still has unrendered runes. The ticker flushes
194	// them, then closes out the stream (clearing flags, resetting
195	// buffers) so the final paint matches the on-disk message.
196	streamFlushPending bool
197	toolCalls          map[string]*tui.ToolCallView
198	toolOrder          []string
199	statusErr          string
200	statusOK           string
201	helpBlock          []string // rendered above the chat when /help was typed
202	cumUsage           provider.Usage
203	lastCtxInput       int // input_tokens of the most recent turn — approximates current context size
204	busy               bool
205	dirty              chan struct{}
206	cancelTurn         context.CancelFunc
207	scrollOffset       int // rows from the bottom; 0 = pinned to latest
208	prevScrollOffset   int // last value redraw snapped against; tracks intent
209
210	// prevChatLen and prevChatCols track the chat buffer's size at the
211	// last redraw so that when content grows below the user's viewport
212	// while they're scrolled up reading history, we can bump
213	// scrollOffset by exactly the growth and keep the visible content
214	// pinned. Without this, every streamed line shifts the visible
215	// window down through the buffer (because scrollOffset is measured
216	// from the bottom) and the user's reading position drifts upward
217	// and off the top.
218	prevChatLen  int
219	prevChatCols int
220
221	// chatCache stores the built transcript/status-note rows for idle
222	// frames. Editor typing changes only the bottom input region, so
223	// reusing this cache avoids copying/walking/reassembling a long
224	// session on every keypress.
225	chatCache      []string
226	chatCacheKey   chatCacheKey
227	chatCacheValid bool
228
229	// Messages typed while a turn is in flight. Each is delivered as
230	// its own follow-up turn once the current one finishes. Rendered
231	// above the status bar as "sliding in: ..." chips.
232	queued []string
233
234	// runCtx is the top-level context passed to Run(). Follow-up turns
235	// drained from `queued` are started against this context so they
236	// survive past the ctx of the key event that enqueued them.
237	runCtx context.Context
238
239	// autoCompacting is true while a model-triggered compaction is in
240	// flight. Surfaced in the status bar so the user can tell a
241	// condense pass from a regular assistant turn.
242	autoCompacting bool
243
244	// updateInfo is the result of the async update check. Zero value
245	// while the check hasn't completed or nothing is available.
246	updateInfo UpdateInfo
247
248	dialog            *loginDialog
249	modelDialog       *modelDialog
250	sessionDialog     *sessionDialog
251	jumpDialog        *jumpDialog
252	btwDialog         *btwDialog
253	skillsDialog      *skillsDialog
254	changelogDialog   *changelogDialog
255	confirmDialog     *confirmDialog
256	logoutDialog      *logoutDialog
257	telegramDialog    *telegramDialog
258	telegramBridge    *telegram.Bridge
259	sessionOpsDialog  *sessionOpsDialog
260	sessionTreeDialog *sessionTreeDialog
261	extPanel          *extPanelDialog
262
263	// pendingFork is true when the user ran /session fork: the next
264	// jump-picker selection should branch off that message instead
265	// of scrolling. Flag resets after the action fires or the dialog
266	// is dismissed, so repeated /jump calls don't turn into forks.
267	pendingFork bool
268	suggest     *slashSuggester
269	fileSuggest *fileSuggester
270	spin        *spinner
271
272	// parkedTurn is the 1-based turn number the viewport is currently
273	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
274	// Rendered as a muted footer at the bottom of the chat so users
275	// don't forget they're looking at history.
276	parkedTurn  int
277	parkedTotal int
278
279	// inputHistoryIndex is -1 when not browsing history. When the
280	// editor is empty, Left/Right can walk previous user prompts for
281	// quick manual testing without stealing normal cursor movement in
282	// non-empty input.
283	inputHistoryIndex int
284
285	// lastCtrlC is when the user last pressed ctrl+c. The first press
286	// clears the editor / cancels a turn / shows a hint; a second press
287	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
288	lastCtrlC time.Time
289
290	// welcomeStart is when the interactive run began. The welcome
291	// banner shows the binary version for welcomeVersionDuration
292	// after this point and reverts to plain text after.
293	welcomeStart time.Time
294
295	// extNotes are one-shot styled lines pushed by extensions via
296	// Notify / Display. They live above the editor (just below the
297	// transcript) until cleared by /clear or another reset.
298	extNotes []string
299}
300
301// welcomeVersionDuration is how long the welcome banner shows the
302// version suffix before reverting to the plain headline. 1.5s is
303// enough to read at a glance and keeps the splash short.
304const welcomeVersionDuration = 1500 * time.Millisecond
305
306// NewInteractive constructs an Interactive from cfg.
307func NewInteractive(cfg InteractiveConfig) *Interactive {
308	i := &Interactive{
309		cfg: cfg,
310		view: &tui.View{
311			Theme:      cfg.Theme,
312			ImageProto: tui.DetectImageProtocol(),
313		},
314		// Prompt is the standard half-block accent bar used by chat
315		// speaker labels too, so the input gutter matches the rest
316		// of the UI.
317		ed:                tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
318		rend:              tui.NewRenderer(cfg.Terminal),
319		toolCalls:         map[string]*tui.ToolCallView{},
320		dirty:             make(chan struct{}, 8),
321		dialog:            newLoginDialog(),
322		modelDialog:       newModelDialog(),
323		sessionDialog:     newSessionDialog(),
324		jumpDialog:        newJumpDialog(),
325		btwDialog:         newBtwDialog(),
326		skillsDialog:      newSkillsDialog(),
327		changelogDialog:   newChangelogDialog(),
328		confirmDialog:     newConfirmDialog(),
329		logoutDialog:      newLogoutDialog(),
330		telegramDialog:    newTelegramDialog(),
331		sessionOpsDialog:  newSessionOpsDialog(),
332		sessionTreeDialog: newSessionTreeDialog(),
333		extPanel:          newExtPanelDialog(),
334		suggest:           newSlashSuggester(),
335		fileSuggest:       newFileSuggester(),
336		spin:              newSpinner(),
337		inputHistoryIndex: -1,
338	}
339	if cfg.Agent != nil {
340		i.agent = cfg.Agent
341		i.view.Messages = cfg.Agent.Messages()
342		i.cumUsage = cfg.Agent.Cost()
343	}
344	return i
345}
346
347// Run blocks until the user quits.
348func (i *Interactive) Run(ctx context.Context) error {
349	i.runCtx = ctx
350	term := i.cfg.Terminal
351	restore, err := term.EnterRaw()
352	if err != nil {
353		return err
354	}
355	defer restore()
356	defer func() {
357		if i.telegramBridge != nil {
358			i.telegramBridge.Stop()
359		}
360	}()
361
362	mouseSeqOn := ""
363	mouseSeqOff := ""
364	if isVSCodeTerminal() {
365		mouseSeqOn = tui.SeqMouseOn
366		mouseSeqOff = tui.SeqMouseOff
367	}
368
369	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn + mouseSeqOn))
370	_, _ = term.Write([]byte(tui.SeqAltScreenOn))
371	defer term.Write([]byte(tui.SeqAltScreenOff + mouseSeqOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
372
373	// Streaming pacer: drains buffered text deltas at a steady rate
374	// so typewriter feel is identical across providers regardless of
375	// upstream chunk size. Starts here so it lives for the whole
376	// session and exits with ctx.
377	go i.runStreamPacer(ctx)
378
379	cols, rows := term.Size()
380	i.rend.Resize(cols, rows)
381	term.OnResize(func() {
382		c, r := term.Size()
383		i.rend.Resize(c, r)
384		// Force an immediate redraw on resize. The throttled invalidate
385		// path is fine for animation, but a window resize is a discrete
386		// user action where any visible delay (or stale frame) reads as
387		// brokenness. redraw() is mutex-safe; the worst that happens is
388		// a duplicate paint if the throttler is mid-flight, which is
389		// invisible.
390		i.redraw()
391	})
392
393	if i.cfg.InitialInput != "" {
394		i.ed.SetValue(i.cfg.InitialInput)
395	}
396
397	// Stamp the welcome time and schedule a one-shot redraw at the
398	// expiry so the version suffix disappears on its own even if the
399	// user hasn't typed anything yet.
400	i.welcomeStart = time.Now()
401	time.AfterFunc(welcomeVersionDuration, i.invalidate)
402
403	// If the agent was constructed with a pre-loaded transcript
404	// (--continue, --resume, --session) park the viewport on the
405	// most recent turn so the user lands looking at where the
406	// previous session left off rather than at the bottom of an
407	// already-rendered final reply.
408	if i.agent != nil {
409		if msgs := i.agent.Messages(); len(msgs) > 0 {
410			i.scrollToLastTurn(msgs)
411		}
412	}
413
414	// No credential at startup? Auto-open the login dialog, and mark
415	// the status line. The user can Esc out of the dialog if they
416	// want to dismiss it (e.g. to check /help or /exit first).
417	if i.agent == nil {
418		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
419		i.dialog.Open(i.cfg.ZotHome)
420	}
421
422	// Input goroutine. Buffered generously so a drag-drop that the
423	// terminal delivers as a burst of single-character key events
424	// (no bracketed paste) can be drained in one main-loop pass
425	// instead of triggering a redraw per character.
426	keys := make(chan tui.Key, 256)
427	go func() {
428		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
429		for {
430			k, err := reader.Read()
431			if err != nil {
432				return
433			}
434			keys <- k
435		}
436	}()
437
438	// Subscribe to auth events.
439	var authEvents <-chan auth.Event
440	if i.cfg.AuthManager != nil {
441		authEvents = i.cfg.AuthManager.Events()
442	}
443
444	// Animation ticker: drives spinner and dialog-related redraws when
445	// nothing else changed. 120ms is slow enough that highlighting a huge
446	// transcript doesn't spin the cpu.
447	tick := time.NewTicker(120 * time.Millisecond)
448	defer tick.Stop()
449
450	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
451	// at most once every redrawMinInterval. Huge tool-result dumps can
452	// fire hundreds of invalidations while the user is typing; without
453	// this, the input goroutine never gets CPU and keystrokes lag.
454	const redrawMinInterval = 16 * time.Millisecond
455	var lastRedraw time.Time
456	var pendingRedraw bool
457	var pendingTimer *time.Timer
458
459	drainPending := func() {
460		if pendingTimer != nil {
461			pendingTimer.Stop()
462			pendingTimer = nil
463		}
464		if pendingRedraw {
465			pendingRedraw = false
466			lastRedraw = time.Now()
467			i.redraw()
468		}
469	}
470
471	requestRedraw := func() {
472		since := time.Since(lastRedraw)
473		if since >= redrawMinInterval {
474			// Redrawing right now subsumes any pending redraw, so clear
475			// the throttle state. Without this, a pending flag stays
476			// stuck at true and subsequent invalidate() calls within
477			// redrawMinInterval get dropped — which is exactly how the
478			// final "turn finished" frame went missing until the user
479			// nudged the ui by typing or scrolling.
480			if pendingTimer != nil {
481				pendingTimer.Stop()
482			}
483			pendingRedraw = false
484			lastRedraw = time.Now()
485			i.redraw()
486			return
487		}
488		if pendingRedraw {
489			return // already scheduled
490		}
491		pendingRedraw = true
492		wait := redrawMinInterval - since
493		if pendingTimer == nil {
494			pendingTimer = time.AfterFunc(wait, func() {
495				// Poke the dirty channel so the main loop wakes and
496				// drains the pending redraw on its own goroutine. We
497				// can't call drainPending here directly — it touches
498				// closure state shared with the main loop.
499				i.invalidate()
500			})
501		} else {
502			pendingTimer.Reset(wait)
503		}
504	}
505
506	i.invalidate()
507
508	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
509	changelog := i.cfg.ChangelogChan // single-shot, see case below
510
511	for {
512		select {
513		case <-ctx.Done():
514			return ctx.Err()
515		case k := <-keys:
516			if done := i.handleKey(ctx, k); done {
517				return nil
518			}
519			// Drain any keystrokes that arrived during this iteration.
520			// VS Code (and other terminals that don't bracket drops as
521			// paste) deliver a path one rune at a time — without this
522			// loop the editor would render between every rune and a
523			// long path on a heavy transcript would visibly type in.
524		drain:
525			for {
526				select {
527				case k2 := <-keys:
528					if done := i.handleKey(ctx, k2); done {
529						return nil
530					}
531				default:
532					break drain
533				}
534			}
535			i.invalidate()
536		case ev := <-authEvents:
537			i.handleAuthEvent(ev)
538			i.invalidate()
539		case info, ok := <-updates:
540			if ok && info.Available {
541				i.mu.Lock()
542				i.updateInfo = info
543				i.mu.Unlock()
544				i.invalidate()
545			}
546			updates = nil // single-shot; subsequent iterations skip this case
547		case cl, ok := <-changelog:
548			if ok && cl.Body != "" {
549				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
550				i.invalidate()
551			}
552			changelog = nil // single-shot
553		case <-i.dirty:
554			requestRedraw()
555		case <-tick.C:
556			// Always drain a pending redraw on the tick. This is the
557			// safety net that catches the case where the dirty channel
558			// was saturated when the final "turn finished" invalidate
559			// fired, or where the throttle scheduled a deferred redraw
560			// and the AfterFunc-driven invalidate got dropped on a
561			// full channel.
562			drainPending()
563			// Only force a periodic redraw when something is actually
564			// animating (the main spinner during a busy turn, or the
565			// btw side-chat spinner while it's awaiting a response).
566			// Static pickers (model, session, jump, etc.) don't need
567			// the tick and firing it cancels the terminal's cursor
568			// blink inside dialogs that host their own editor (btw),
569			// because each frame re-emits hide-cursor + show-cursor.
570			if i.busy || i.btwDialog.Loading() {
571				requestRedraw()
572			}
573		}
574	}
575}
576
577func (i *Interactive) invalidate() {
578	select {
579	case i.dirty <- struct{}{}:
580	default:
581	}
582}
583
584func isVSCodeTerminal() bool {
585	return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode")
586}
587
588func mouseWheelScrollRows() int {
589	// VS Code's integrated terminal emits relatively small wheel
590	// steps compared with Ghostty's native scrolling. Mouse-wheel
591	// events get a bigger delta than keyboard arrows so trackpads and
592	// wheel mice feel responsive without changing Up/Down behaviour.
593	if isVSCodeTerminal() {
594		return 12
595	}
596	return 6
597}
598
599func (i *Interactive) cachedChatLocked(cols int) []string {
600	key, cacheable := i.chatCacheKeyLocked(cols)
601	if cacheable && i.chatCacheValid && i.chatCacheKey == key {
602		return append([]string(nil), i.chatCache...)
603	}
604	chat := i.buildChatLocked(cols)
605	if cacheable {
606		i.chatCache = append(i.chatCache[:0], chat...)
607		i.chatCacheKey = key
608		i.chatCacheValid = true
609	} else {
610		i.chatCacheValid = false
611	}
612	return chat
613}
614
615func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
616	// Live turns mutate streaming/tool-call state at high frequency;
617	// keep those on the old rebuild path. The cache targets the common
618	// idle case where only the editor contents changed between redraws.
619	if i.busy || i.streamOn || i.streamFlushPending {
620		return chatCacheKey{}, false
621	}
622	var rev uint64
623	if i.agent != nil {
624		rev = i.agent.Revision()
625	}
626	showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
627	return chatCacheKey{
628		cols:            cols,
629		agentRev:        rev,
630		statusOK:        i.statusOK,
631		statusErr:       i.statusErr,
632		help:            strings.Join(i.helpBlock, "\n"),
633		extNotes:        strings.Join(i.extNotes, "\n"),
634		updateAvailable: i.updateInfo.Available,
635		updateCurrent:   i.updateInfo.Current,
636		updateLatest:    i.updateInfo.Latest,
637		updateURL:       i.updateInfo.URL,
638		welcomeShowVer:  showVer,
639		expandAll:       i.view.ExpandAll,
640	}, true
641}
642
643func (i *Interactive) buildChatLocked(cols int) []string {
644	if i.agent != nil {
645		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
646	} else {
647		i.view.Messages = nil
648	}
649	// Pacer flush: while the streaming pacer is still draining the
650	// buffer (i.e. EvAssistantMessage already fired but more runes
651	// are queued), the final assistant message is already in
652	// i.agent.Messages() in full. Painting it in the transcript
653	// AND the streaming block at the same time shows the user the
654	// complete text immediately — which defeats the whole pacer.
655	// Hide the last message until the pacer catches up; once the
656	// flush-pending latch clears, the message is revealed (the
657	// streaming block disappears the same frame because streamOn
658	// flips off, so the transition is seamless).
659	if i.streamFlushPending && len(i.view.Messages) > 0 {
660		i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
661	}
662	i.view.Streaming = i.streaming.String()
663	i.view.StreamingActive = i.streamOn
664	// Guard against the narrow race where EvAssistantMessage has
665	// just promoted a streaming reply into the transcript but a
666	// render tick hasn't flipped streamOn off yet. Without the
667	// guard, the same text would appear twice (once as the
668	// in-flight streaming block, once as the last transcript
669	// message). We detect the duplicate strictly: the last
670	// assistant message's visible text must equal the streaming
671	// buffer. Just matching on role is too broad — it also hides
672	// the next round's typewriter streaming after a tool turn,
673	// because the last transcript message is always assistant
674	// (the tool-use block) until the follow-up summary lands.
675	if i.streamOn && i.streaming.Len() > 0 {
676		if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
677			if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
678				i.view.StreamingActive = false
679			}
680		}
681	}
682	// Live tool-call view: only shown while a turn is in flight. Once
683	// the agent is idle, every tool call has already been folded into
684	// the transcript (as assistant.ToolCallBlock + a tool-role message),
685	// so showing v.ToolCalls a second time would duplicate them below
686	// the final assistant text — which looks like the summary came
687	// "before" the tools.
688	i.view.ToolCalls = i.view.ToolCalls[:0]
689	if i.busy {
690		for _, id := range i.toolOrder {
691			if tc, ok := i.toolCalls[id]; ok {
692				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
693			}
694		}
695	}
696	i.view.Err = i.statusErr
697
698	chat := i.view.Build(cols)
699
700	// Welcome banner: shown at the top of the chat area when there is
701	// no transcript yet. Disappears after the first message is sent.
702	// The version suffix is shown for welcomeVersionDuration after
703	// startup, then drops off automatically.
704	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
705		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
706		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
707	}
708
709	// Update-available banner: prepended above everything else so it's
710	// the first thing the user sees when opening a new zot session.
711	// Once rendered, it stays until the user updates to a newer
712	// version — we don't persist a "dismissed" flag because this is
713	// cheap and re-showing it is how most users remember to update.
714	if i.updateInfo.Available {
715		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
716		chat = append(banner, chat...)
717	}
718
719	// /help block: appended to the transcript so it appears at the
720	// bottom of the chat area (right above the status bar / editor).
721	// Prepending it would push long conversations off the top of the
722	// viewport, which users would miss entirely.
723	if len(i.helpBlock) > 0 {
724		chat = append(chat, i.helpBlock...)
725	}
726
727	if i.statusOK != "" {
728		// Hard-truncate the OK line to the visible width so a long
729		// session path ("resumed session: /Users/.../sessions/...")
730		// doesn't overflow past the right edge and look broken on a
731		// narrow terminal.
732		line := "✓ " + i.statusOK
733		if cols > 4 && len(line) > cols {
734			line = line[:cols-1] + "…"
735		}
736		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
737	}
738
739	// Extension notes (notify / display) live just under the
740	// transcript, above the dialog/editor band. Cleared by /clear.
741	if len(i.extNotes) > 0 {
742		chat = append(chat, i.extNotes...)
743		chat = append(chat, "")
744	}
745
746	// Strip trailing blank rows so the chat content sits flush
747	// against the new "blank above status bar" row added by the
748	// bottom-region assembly. Build() ends every message with a
749	// blank separator; without this trim, the final message in
750	// the transcript would have its own trailing blank plus the
751	// status block's leading blank, doubling the gap.
752	for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
753		chat = chat[:len(chat)-1]
754	}
755	return chat
756}
757
758// lastCols returns the current terminal width in columns.
759func (i *Interactive) lastCols() int {
760	cols, _ := i.cfg.Terminal.Size()
761	return cols
762}
763
764// chatPage returns the number of chat rows currently visible, used
765// as the page size for PageUp/PageDown.
766func (i *Interactive) chatPage() int {
767	_, rows := i.cfg.Terminal.Size()
768	p := rows - 6 // rough reservation for status + editor + a dialog line
769	if p < 4 {
770		p = 4
771	}
772	return p
773}
774
775// scrollBy adjusts the scroll offset. Positive = up (into history).
776// Clearing the parked-turn label when we're back at the bottom means
777// the "viewing turn N" footer goes away automatically as soon as you
778// scroll back to the live tail.
779func (i *Interactive) scrollBy(delta int) {
780	i.mu.Lock()
781	i.scrollOffset += delta
782	if i.scrollOffset < 0 {
783		i.scrollOffset = 0
784	}
785	if i.scrollOffset == 0 {
786		i.parkedTurn = 0
787		i.parkedTotal = 0
788	}
789	if i.rend != nil {
790		// VS Code's terminal is especially prone to leaving stray
791		// wrapped-character fragments behind during scroll-driven
792		// viewport changes. Force a full repaint on scroll, but
793		// avoid a whole-screen clear because that visibly flickers.
794		i.rend.Invalidate()
795	}
796	i.mu.Unlock()
797	i.invalidate()
798}
799
800// scrollToBottom pins the view to the latest content.
801func (i *Interactive) scrollToBottom() {
802	i.mu.Lock()
803	i.scrollOffset = 0
804	i.parkedTurn = 0
805	i.parkedTotal = 0
806	if i.rend != nil {
807		i.rend.Invalidate()
808	}
809	i.mu.Unlock()
810	i.invalidate()
811}
812
813func (i *Interactive) redraw() {
814	i.mu.Lock()
815	defer i.mu.Unlock()
816
817	cols, _ := i.cfg.Terminal.Size()
818	chat := i.cachedChatLocked(cols)
819
820	// Dialogs (login or model picker) render between chat and the editor.
821	var dialog []string
822	switch {
823	case i.dialog.Active():
824		dialog = i.dialog.Render(i.cfg.Theme, cols)
825	case i.modelDialog.Active():
826		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
827	case i.sessionDialog.Active():
828		// Reserve rows for the editor (~3), status line (1-2),
829		// dialog chrome (header + hint + rule + indicators, ~5),
830		// and leave the remainder for session rows. Minimum of 3
831		// rows so even a very small terminal shows something.
832		_, rows := i.cfg.Terminal.Size()
833		avail := rows - 12
834		if avail < 3 {
835			avail = 3
836		}
837		i.sessionDialog.MaxRows = avail
838		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
839	case i.jumpDialog.Active():
840		dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
841	case i.btwDialog.Active():
842		dialog = i.btwDialog.Render(i.cfg.Theme, cols)
843	case i.skillsDialog.Active():
844		dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
845	case i.changelogDialog.Active():
846		dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
847	case i.confirmDialog.Active():
848		dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
849	case i.logoutDialog.Active():
850		dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
851	case i.telegramDialog.Active():
852		dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
853	case i.sessionOpsDialog.Active():
854		dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
855	case i.sessionTreeDialog.Active():
856		dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
857	case i.extPanel.Active():
858		dialog = i.extPanel.Render(i.cfg.Theme, cols)
859	}
860
861	// Slash-command autocomplete: popup above the status line, only
862	// when the editor starts with "/" and no dialog is already open.
863	// Feed extension-registered commands into the suggester first so
864	// they show up in tab-complete + the popup alongside the built-ins.
865	if i.cfg.Extensions != nil {
866		catalog := i.cfg.Extensions.Commands()
867		extra := make([]slashCommand, 0, len(catalog))
868		for _, c := range catalog {
869			// The popup renders extension commands under a dedicated
870			// "── extensions ───" divider, so the description doesn't
871			// need to repeat the source. If the description is empty,
872			// fall back to the extension name so the row isn't blank.
873			desc := c.Description
874			if strings.TrimSpace(desc) == "" {
875				desc = "(" + c.Extension + ")"
876			}
877			extra = append(extra, slashCommand{
878				Name: "/" + c.Name,
879				Desc: desc,
880			})
881		}
882		i.suggest.SetExtra(extra)
883	}
884	var suggest []string
885	currentInput := i.ed.Value()
886	// Slash popup renders even while the agent is busy so the user
887	// can queue a destructive command (/clear, /compact, /logout,
888	// /model) or a read-only one (/help, /jump, /sessions, etc.)
889	// without waiting for the current turn to finish. The dispatcher
890	// in runSlash already handles the busy case per-command: safe
891	// ones run immediately, destructive ones cancel the turn first.
892	i.fileSuggest.SetCWD(i.cfg.CWD)
893	if len(dialog) == 0 && i.suggest.Active(currentInput) {
894		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
895	} else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) {
896		suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
897	}
898	if len(suggest) > 0 {
899		// One blank row above the popup so it doesn't sit flush
900		// against the chat / welcome content above.
901		suggest = append([]string{""}, suggest...)
902	}
903
904	// Busy prefix shown at the far left of the status bar. The
905	// spinner glyph and its funny-line message share the `zot`
906	// label colour (Theme.Assistant) so the whole "who's working"
907	// band reads at a glance. Elapsed time stays muted because it
908	// drifts every second and shouldn't grab focus.
909	var busyPrefix string
910	if i.busy {
911		busyPrefix = fmt.Sprintf("%s %s %s %s",
912			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
913			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
914			i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
915			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
916		)
917	}
918
919	ctxMax := 0
920	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
921		ctxMax = m.ContextWindow
922	}
923	statusLines := tui.StatusBar(tui.StatusBarParams{
924		Theme:          i.cfg.Theme,
925		Provider:       i.cfg.Provider,
926		Model:          i.cfg.Model,
927		Busy:           i.busy,
928		BusyPrefix:     busyPrefix,
929		CWD:            i.cfg.CWD,
930		Locked:         i.cfg.Sandbox.Locked(),
931		NoYolo:         i.cfg.NoYolo,
932		Usage:          i.cumUsage,
933		Subscription:   i.cfg.AuthMethod == "oauth",
934		ContextUsed:    i.lastCtxInput,
935		ContextMax:     ctxMax,
936		AutoCompacting: i.autoCompacting,
937		Telegram:       i.telegramBridge != nil && i.telegramBridge.Active(),
938		Cols:           cols,
939	})
940	edLines, curR, curC := i.ed.Render(cols)
941
942	// "Sliding in" chips for messages the user typed while a turn is
943	// in flight. Shown directly above the status bar so they're close
944	// to the editor but don't push the chat around.
945	var queue []string
946	if len(i.queued) > 0 {
947		queue = append(queue, "")
948		for _, q := range i.queued {
949			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "  sliding in: ")
950			text := truncateLine(q, cols-17)
951			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
952		}
953	}
954
955	// Bottom-sticky sections (always visible, never scroll). Each
956	// non-empty subsection (dialog, suggest popup, sliding-in queue)
957	// is preceded by one blank row so it has air above the chat
958	// content. The status block and editor get their own dedicated
959	// blanks so spacing stays consistent whether or not a dialog or
960	// popup is showing.
961	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+8)
962	if len(dialog) > 0 {
963		bottom = append(bottom, "")
964	}
965	bottom = append(bottom, dialog...)
966	bottom = append(bottom, suggest...)
967	bottom = append(bottom, queue...)
968	bottom = append(bottom, "")
969	bottom = append(bottom, statusLines...)
970	bottom = append(bottom, "")
971	bottom = append(bottom, edLines...)
972	bottom = append(bottom, "")
973
974	_, rows := i.cfg.Terminal.Size()
975	chatRows := rows - len(bottom)
976	if chatRows < 1 {
977		chatRows = 1
978	}
979
980	// Auto-follow guard: when the user has scrolled up (scrollOffset
981	// > 0) and the agent appends new content below the viewport while
982	// they're reading, compensate so the visible content stays
983	// anchored. scrollOffset is measured from the bottom of `chat`,
984	// so without compensation a growing buffer pushes the window
985	// downward through the content and the lines the user was
986	// reading scroll off the top.
987	//
988	// Skip compensation when the terminal width changed (a resize
989	// reflows the whole buffer and the line-count delta no longer
990	// corresponds to appended content) and when scrollOffset is 0
991	// (the user is following the tail and wants new content to push
992	// the view down as usual).
993	if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
994		if delta := len(chat) - i.prevChatLen; delta != 0 {
995			i.scrollOffset += delta
996			if i.scrollOffset < 0 {
997				i.scrollOffset = 0
998			}
999		}
1000	}
1001	i.prevChatLen = len(chat)
1002	i.prevChatCols = cols
1003
1004	// Apply scroll offset to the chat slice.
1005	maxOffset := len(chat) - chatRows
1006	if maxOffset < 0 {
1007		maxOffset = 0
1008	}
1009	if i.scrollOffset > maxOffset {
1010		i.scrollOffset = maxOffset
1011	}
1012	if i.scrollOffset < 0 {
1013		i.scrollOffset = 0
1014	}
1015
1016	var visibleChat []string
1017	if len(chat) <= chatRows {
1018		visibleChat = chat
1019	} else {
1020		end := len(chat) - i.scrollOffset
1021		rawStart := end - chatRows
1022		if rawStart < 0 {
1023			rawStart = 0
1024		}
1025		start := snapViewportStartToImageBlock(chat, rawStart)
1026		// If the snap pulled start upward (an image-block was atomic) while
1027		// the user is scrolling downward, the viewport would sit on the same
1028		// image until the user mashes down past every reserved row. Bump
1029		// scrollOffset past the image so one keypress always clears it.
1030		if start < rawStart && i.scrollOffset < i.prevScrollOffset {
1031			jump := rawStart - start
1032			i.scrollOffset -= jump
1033			if i.scrollOffset < 0 {
1034				i.scrollOffset = 0
1035			}
1036			end = len(chat) - i.scrollOffset
1037			rawStart = end - chatRows
1038			if rawStart < 0 {
1039				rawStart = 0
1040			}
1041			start = snapViewportStartToImageBlock(chat, rawStart)
1042		}
1043		end = start + chatRows
1044		if end > len(chat) {
1045			end = len(chat)
1046			start = end - chatRows
1047			if start < 0 {
1048				start = 0
1049			}
1050		}
1051		visibleChat = chat[start:end]
1052	}
1053	i.prevScrollOffset = i.scrollOffset
1054	visibleChat = clipBottomClippedImages(visibleChat)
1055
1056	// A tiny "scrolled up" indicator in the top-right of the chat pane
1057	// so you know you're not at the bottom. When the viewport was
1058	// parked by /jump we include the turn number so the user remembers
1059	// they're reading history rather than the live conversation.
1060	if i.scrollOffset > 0 && len(visibleChat) > 0 {
1061		var text string
1062		if i.parkedTurn > 0 && i.parkedTotal > 0 {
1063			text = fmt.Sprintf("  ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
1064				i.parkedTurn, i.parkedTotal, i.scrollOffset)
1065		} else {
1066			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
1067		}
1068		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
1069		visibleChat = append([]string{note}, visibleChat...)
1070		if len(visibleChat) > chatRows {
1071			visibleChat = visibleChat[:chatRows]
1072		}
1073	}
1074
1075	frame := make([]string, 0, len(visibleChat)+len(bottom))
1076	frame = append(frame, visibleChat...)
1077	frame = append(frame, bottom...)
1078
1079	// Default: the real terminal cursor sits on the main editor's
1080	// input position. When an overlay dialog has its own input
1081	// field (today just /btw), route the cursor there instead so
1082	// the blinking cursor shows where the user is actually typing.
1083	// Dialogs without a cursor (model picker, /help, /login, etc.)
1084	// return -1 and the main editor keeps the cursor.
1085	// dialogLead is 1 when the bottom region prepends a blank above
1086	// the dialog block (whenever a dialog is showing) so popup-side
1087	// cursor positions still land in the right cell.
1088	dialogLead := 0
1089	if len(dialog) > 0 {
1090		dialogLead = 1
1091	}
1092	// +2 accounts for the blank row above statusLines (so the
1093	// status block has air above it) and the blank row between
1094	// statusLines and edLines (input breathing room). Without
1095	// these the rendered cursor would land on a blank instead of
1096	// inside the editor row.
1097	cursorRow := len(visibleChat) + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1098	cursorCol := curC
1099	if i.btwDialog.Active() {
1100		if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1101			cursorRow = len(visibleChat) + dialogLead + r
1102			cursorCol = c
1103		}
1104	}
1105	if i.dialog.Active() {
1106		if r, c := i.dialog.CursorPos(cols); r >= 0 {
1107			cursorRow = len(visibleChat) + dialogLead + r
1108			cursorCol = c
1109		}
1110	}
1111	if i.sessionDialog.Active() {
1112		if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1113			cursorRow = len(visibleChat) + dialogLead + r
1114			cursorCol = c
1115		}
1116	}
1117	if i.extPanel.Active() {
1118		cursorRow = -1
1119		cursorCol = 0
1120	}
1121	i.rend.Draw(frame, cursorRow, cursorCol)
1122}
1123
1124func hasImageEscape(line string) bool {
1125	return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1126}
1127
1128// snapViewportStartToImageBlock treats inline images as atomic blocks for
1129// scrolling. Terminal image protocols draw from a single escape row into a
1130// separate graphics layer; the following blank rows are only zot's reserved
1131// footprint. If the viewport starts on one of those blank rows, there is no
1132// correct partial-image state to render. Snap back to the escape row instead
1133// so the image is either shown from its beginning or skipped entirely.
1134func snapViewportStartToImageBlock(chat []string, start int) int {
1135	if start <= 0 || start >= len(chat) {
1136		return start
1137	}
1138	if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1139		return start
1140	}
1141	for k := start - 1; k >= 0; k-- {
1142		line := chat[k]
1143		if hasImageEscape(line) {
1144			return k
1145		}
1146		if !isBoxBlankLine(line) {
1147			break
1148		}
1149	}
1150	return start
1151}
1152
1153const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1154
1155func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1156	if len(msgs) == 0 {
1157		return nil
1158	}
1159	out := make([]provider.Message, 0, len(msgs))
1160	for _, m := range msgs {
1161		if isHiddenTranscriptMessage(m) {
1162			continue
1163		}
1164		out = append(out, m)
1165	}
1166	return out
1167}
1168
1169func isHiddenTranscriptMessage(m provider.Message) bool {
1170	if m.Role != provider.RoleUser || len(m.Content) == 0 {
1171		return false
1172	}
1173	tb, ok := m.Content[0].(provider.TextBlock)
1174	if !ok {
1175		return false
1176	}
1177	return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1178}
1179
1180func clipBottomClippedImages(lines []string) []string {
1181	if len(lines) == 0 {
1182		return lines
1183	}
1184	out := append([]string(nil), lines...)
1185	for i, line := range out {
1186		if !hasImageEscape(line) {
1187			continue
1188		}
1189		// Image blocks render as: image escape, zero or more blank
1190		// reservation rows, then the muted "image - ..." info line,
1191		// then one trailing blank. If the info line isn't visible in
1192		// the current chat slice, the image would paint down into the
1193		// fixed status bar area. Suppress that image for this frame.
1194		//
1195		// When the image lives inside a tool box, the reservation rows
1196		// are wrapped in vertical box edges ("│  …  │"); those rows
1197		// look non-blank under a naive whitespace check but are still
1198		// reservation rows for this scan, so treat them as blank.
1199		foundInfo := false
1200		for j := i + 1; j < len(out); j++ {
1201			if strings.Contains(out[j], "image - ") {
1202				foundInfo = true
1203				break
1204			}
1205			if !isBoxBlankLine(out[j]) {
1206				break
1207			}
1208		}
1209		if !foundInfo {
1210			out[i] = ""
1211		}
1212	}
1213	return out
1214}
1215
1216// isBoxBlankLine reports whether line is visually empty after
1217// stripping ANSI escape sequences, surrounding whitespace, and the
1218// vertical box edges drawn by the tool-box renderer. Used by
1219// clipBottomClippedImages so an image's reservation rows still count
1220// as blank when those rows are wrapped in "│  …  │" inside a tool box.
1221func isBoxBlankLine(line string) bool {
1222	stripped := stripANSIBytes(line)
1223	stripped = strings.TrimSpace(stripped)
1224	stripped = strings.Trim(stripped, "│")
1225	stripped = strings.TrimSpace(stripped)
1226	return stripped == ""
1227}
1228
1229// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final
1230// byte) from s without pulling in the regexp package. Mirrors the
1231// internal helper in package tui; the duplicated copy avoids exporting
1232// it just for one caller.
1233func stripANSIBytes(s string) string {
1234	if !strings.Contains(s, "\x1b") {
1235		return s
1236	}
1237	var b strings.Builder
1238	b.Grow(len(s))
1239	i := 0
1240	for i < len(s) {
1241		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1242			end := i + 2
1243			for end < len(s) {
1244				c := s[end]
1245				end++
1246				if c >= 0x40 && c <= 0x7e {
1247					break
1248				}
1249			}
1250			i = end
1251			continue
1252		}
1253		b.WriteByte(s[i])
1254		i++
1255	}
1256	return b.String()
1257}
1258
1259// truncateLine shortens s so it fits within n display cells, with an
1260// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1261// novel doesn't blow past the status line.
1262func panelKeyName(k tui.Key) string {
1263	switch k.Kind {
1264	case tui.KeyUp:
1265		return "up"
1266	case tui.KeyDown:
1267		return "down"
1268	case tui.KeyLeft:
1269		return "left"
1270	case tui.KeyRight:
1271		return "right"
1272	case tui.KeyEnter:
1273		return "enter"
1274	case tui.KeyEsc:
1275		return "esc"
1276	case tui.KeyTab:
1277		return "tab"
1278	case tui.KeyBackspace:
1279		return "backspace"
1280	case tui.KeyDelete:
1281		return "delete"
1282	case tui.KeyHome:
1283		return "home"
1284	case tui.KeyEnd:
1285		return "end"
1286	case tui.KeyPageUp:
1287		return "pageup"
1288	case tui.KeyPageDown:
1289		return "pagedown"
1290	case tui.KeyRune:
1291		return "rune"
1292	default:
1293		return "unknown"
1294	}
1295}
1296
1297func panelKeyText(k tui.Key) string {
1298	if k.Kind == tui.KeyRune {
1299		return string(k.Rune)
1300	}
1301	return ""
1302}
1303
1304func truncateLine(s string, n int) string {
1305	if n <= 0 {
1306		return ""
1307	}
1308	// Collapse newlines — chips are single line.
1309	s = strings.ReplaceAll(s, "\n", " ↩ ")
1310	runes := []rune(s)
1311	if len(runes) <= n {
1312		return s
1313	}
1314	if n <= 1 {
1315		return "…"
1316	}
1317	return string(runes[:n-1]) + "…"
1318}
1319
1320// ctrlCExitWindow is how long after a ctrl+c press a *second* press
1321// will exit instead of just clearing input. Long enough to be
1322// deliberate (rules out accidental key chord), short enough that the
1323// hint stays meaningful.
1324const ctrlCExitWindow = 2 * time.Second
1325
1326// armCtrlCExit records the timestamp of the current ctrl+c so the next
1327// one within ctrlCExitWindow exits.
1328func (i *Interactive) armCtrlCExit() {
1329	i.mu.Lock()
1330	i.lastCtrlC = time.Now()
1331	i.mu.Unlock()
1332}
1333
1334// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
1335// that another press should now exit.
1336func (i *Interactive) ctrlCExitArmed() bool {
1337	i.mu.Lock()
1338	t := i.lastCtrlC
1339	i.mu.Unlock()
1340	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
1341}
1342
1343func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1344	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1345	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
1346	// unexpectedly. The hint message also goes stale; clear it.
1347	if k.Kind != tui.KeyCtrlC {
1348		i.mu.Lock()
1349		if !i.lastCtrlC.IsZero() {
1350			i.lastCtrlC = time.Time{}
1351			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
1352				i.statusOK = ""
1353			}
1354		}
1355		i.mu.Unlock()
1356	}
1357
1358	// Dialogs consume keys while open (except ctrl+c, which always closes them).
1359
1360	// Confirm dialog has highest priority: the agent goroutine is
1361	// blocked waiting for an answer, so we must not let keys leak
1362	// anywhere else while it's up.
1363	if i.confirmDialog.Active() {
1364		i.confirmDialog.HandleKey(k)
1365		i.invalidate()
1366		return false
1367	}
1368	if i.dialog.Active() {
1369		if k.Kind == tui.KeyCtrlC {
1370			i.dialog.Close()
1371			if i.cfg.AuthManager != nil {
1372				i.cfg.AuthManager.CancelOAuth()
1373			}
1374			return false
1375		}
1376		act := i.dialog.HandleKey(k)
1377		if act.StartAPIKey {
1378			i.startAPIKeyFlow(act.Provider)
1379		}
1380		if act.StartOAuth {
1381			i.startOAuthFlow(act.Provider)
1382		}
1383		if act.StartManual {
1384			i.startManualOAuthFlow(act.Provider)
1385		}
1386		if act.SubmitCode != "" {
1387			i.submitManualOAuthCode(act.SubmitCode)
1388		}
1389		return false
1390	}
1391	if i.modelDialog.Active() {
1392		if k.Kind == tui.KeyCtrlC {
1393			i.modelDialog.Close()
1394			return false
1395		}
1396		act := i.modelDialog.HandleKey(k)
1397		if act.Select {
1398			i.applyModelSelection(act.Provider, act.Model)
1399		}
1400		return false
1401	}
1402	if i.sessionDialog.Active() {
1403		if k.Kind == tui.KeyCtrlC {
1404			i.sessionDialog.Close()
1405			return false
1406		}
1407		act := i.sessionDialog.HandleKey(k)
1408		if act.Select {
1409			i.applySessionSelection(act.Path)
1410		}
1411		return false
1412	}
1413	if i.logoutDialog.Active() {
1414		if k.Kind == tui.KeyCtrlC {
1415			i.logoutDialog.Close()
1416			i.invalidate()
1417			return false
1418		}
1419		act := i.logoutDialog.HandleKey(k)
1420		if act.Select {
1421			i.doLogout(act.Target)
1422		}
1423		i.invalidate()
1424		return false
1425	}
1426	if i.telegramDialog.Active() {
1427		if k.Kind == tui.KeyCtrlC {
1428			i.telegramDialog.Close()
1429			i.invalidate()
1430			return false
1431		}
1432		act := i.telegramDialog.HandleKey(k)
1433		if act.Select {
1434			i.doTelegram(act.Action)
1435		}
1436		i.invalidate()
1437		return false
1438	}
1439	if i.sessionOpsDialog.Active() {
1440		if k.Kind == tui.KeyCtrlC {
1441			i.sessionOpsDialog.Close()
1442			i.invalidate()
1443			return false
1444		}
1445		act := i.sessionOpsDialog.HandleKey(k)
1446		if act.Select {
1447			i.doSessionOp(act.Action, "")
1448		}
1449		i.invalidate()
1450		return false
1451	}
1452	if i.sessionTreeDialog.Active() {
1453		if k.Kind == tui.KeyCtrlC {
1454			i.sessionTreeDialog.Close()
1455			i.invalidate()
1456			return false
1457		}
1458		act := i.sessionTreeDialog.HandleKey(k)
1459		if act.Select {
1460			i.applySessionTreeSelection(act.Path)
1461		}
1462		i.invalidate()
1463		return false
1464	}
1465	if i.extPanel.Active() {
1466		if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
1467			if i.cfg.Extensions != nil {
1468				_ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
1469			}
1470			i.extPanel.Close()
1471			i.invalidate()
1472			return false
1473		}
1474		if i.cfg.Extensions != nil {
1475			_ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
1476		}
1477		return false
1478	}
1479	if i.jumpDialog.Active() {
1480		if k.Kind == tui.KeyCtrlC {
1481			i.jumpDialog.Close()
1482			i.pendingFork = false
1483			return false
1484		}
1485		act := i.jumpDialog.HandleKey(k)
1486		if act.Select {
1487			if i.pendingFork {
1488				i.applyForkSelection(act.MessageIdx)
1489			} else {
1490				i.applyJumpSelection(act.MessageIdx, act.TurnNo)
1491			}
1492		}
1493		// If the user dismissed the dialog without selecting, also
1494		// clear the pending-fork flag so a later plain /jump isn't
1495		// hijacked.
1496		if act.Close {
1497			i.pendingFork = false
1498		}
1499		return false
1500	}
1501	if i.btwDialog.Active() {
1502		if k.Kind == tui.KeyCtrlC {
1503			i.btwDialog.Close()
1504			i.invalidate()
1505			return false
1506		}
1507		i.btwDialog.HandleKey(k, i.invalidate)
1508		return false
1509	}
1510	if i.skillsDialog.Active() {
1511		if k.Kind == tui.KeyCtrlC {
1512			i.skillsDialog.Close()
1513			i.invalidate()
1514			return false
1515		}
1516		i.skillsDialog.HandleKey(k)
1517		i.invalidate()
1518		return false
1519	}
1520	if i.changelogDialog.Active() {
1521		if closed := i.changelogDialog.HandleKey(k); closed {
1522			// User dismissed; let the parent persist the
1523			// LastChangelogShown marker via the close callback.
1524			if i.cfg.OnChangelogDismiss != nil {
1525				i.cfg.OnChangelogDismiss()
1526			}
1527		}
1528		i.invalidate()
1529		return false
1530	}
1531
1532	// Global keys.
1533	switch k.Kind {
1534	case tui.KeyCtrlC:
1535		// While busy: do NOT cancel the turn. ctrl+c during a
1536		// running turn is almost always reflex muscle memory
1537		// ("be quiet" in a shell) rather than a deliberate
1538		// decision to kill a multi-minute model call that's
1539		// already cost tokens. Use esc to interrupt a turn; use
1540		// a deliberate double-ctrl+c to exit zot entirely. First
1541		// press arms the exit hint, second press within
1542		// ctrlCExitWindow quits.
1543		if i.busy {
1544			if i.ctrlCExitArmed() {
1545				return true
1546			}
1547			i.mu.Lock()
1548			i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
1549			i.statusErr = ""
1550			i.mu.Unlock()
1551			i.armCtrlCExit()
1552			return false
1553		}
1554		// Idle: first press clears the editor (and any queued
1555		// follow-up messages); a second press within ctrlCExitWindow
1556		// exits. With both an empty editor and no queue the first
1557		// press still just arms — require a deliberate double-tap.
1558		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
1559		if hadInput {
1560			i.ed.Clear()
1561			i.suggest.Reset()
1562			i.mu.Lock()
1563			i.queued = nil
1564			i.statusOK = "input cleared"
1565			i.statusErr = ""
1566			i.mu.Unlock()
1567			i.armCtrlCExit()
1568			return false
1569		}
1570		if i.ctrlCExitArmed() {
1571			return true
1572		}
1573		i.mu.Lock()
1574		i.statusOK = "press ctrl+c again to exit"
1575		i.statusErr = ""
1576		i.mu.Unlock()
1577		i.armCtrlCExit()
1578		return false
1579	case tui.KeyEsc:
1580		// Esc interrupts a running turn — but only when nothing
1581		// else on screen wants to consume the key first. The slash
1582		// popup has its own Esc behaviour (close + clear editor),
1583		// and transient overlays like the /help block and extension
1584		// notes should dismiss on Esc before we even consider the
1585		// turn. Without these guards, a casual Esc press after
1586		// running /help on a busy turn rips the turn away.
1587		if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
1588			break
1589		}
1590		i.mu.Lock()
1591		hadHelp := len(i.helpBlock) > 0
1592		hadNotes := len(i.extNotes) > 0
1593		if hadHelp {
1594			i.helpBlock = nil
1595		}
1596		if hadNotes {
1597			i.extNotes = nil
1598		}
1599		i.mu.Unlock()
1600		if hadHelp || hadNotes {
1601			i.invalidate()
1602			return false
1603		}
1604		if i.busy && i.cancelTurn != nil {
1605			i.cancelTurn()
1606			// If a confirm dialog is pending, refuse it so the agent
1607			// goroutine unblocks and the context cancellation can
1608			// actually take effect.
1609			i.confirmDialog.CancelAll("turn cancelled")
1610			return false
1611		}
1612	case tui.KeyCtrlD:
1613		if i.ed.IsEmpty() && !i.busy {
1614			return true
1615		}
1616	case tui.KeyCtrlL:
1617		i.rend.Clear()
1618		i.invalidate()
1619		return false
1620	case tui.KeyCtrlO:
1621		// Toggle expansion of collapsed tool results. Affects every tool
1622		// call in the transcript — press again to re-collapse.
1623		i.mu.Lock()
1624		i.view.ExpandAll = !i.view.ExpandAll
1625		i.mu.Unlock()
1626		i.invalidate()
1627		return false
1628	case tui.KeyPageUp:
1629		i.scrollBy(+i.chatPage())
1630		return false
1631	case tui.KeyPageDown:
1632		i.scrollBy(-i.chatPage())
1633		return false
1634	case tui.KeyMouseWheelUp:
1635		i.scrollBy(+mouseWheelScrollRows())
1636		return false
1637	case tui.KeyMouseWheelDown:
1638		if i.scrollOffset > 0 {
1639			i.scrollBy(-mouseWheelScrollRows())
1640		}
1641		return false
1642	case tui.KeyUp:
1643		// Always use up/down for chat scrolling, even when the editor
1644		// contains text. This makes keyboard scrolling consistent with
1645		// a draft present at the cost of disabling vertical cursor
1646		// motion inside the multi-line editor. Keep slash-popup
1647		// navigation working by letting it intercept later when active.
1648		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1649			i.scrollBy(+3)
1650			return false
1651		}
1652	case tui.KeyDown:
1653		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1654			if i.scrollOffset > 0 {
1655				i.scrollBy(-3)
1656			}
1657			return false
1658		}
1659	}
1660
1661	// Note: we intentionally do NOT gate the editor on i.busy here.
1662	// Typing while the agent is working is supported — submitted
1663	// messages are queued and delivered as follow-up turns when the
1664	// current turn ends. See the submit handler below.
1665
1666	if k.Kind == tui.KeyEnter && k.Alt {
1667		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1668		return false
1669	}
1670
1671	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1672	if i.suggest.Active(i.ed.Value()) {
1673		switch k.Kind {
1674		case tui.KeyUp:
1675			i.suggest.Up()
1676			return false
1677		case tui.KeyDown:
1678			i.suggest.Down()
1679			return false
1680		case tui.KeyTab:
1681			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1682				i.ed.SetValue(name)
1683				i.suggest.Reset()
1684			}
1685			return false
1686		case tui.KeyEnter:
1687			// Enter on an ambiguous or partial slash prefix: complete to the
1688			// currently highlighted command and run it. That way typing
1689			// "/lo" + enter picks whichever of /login or /logout is selected
1690			// in the popup instead of submitting "/lo" as unknown. Also
1691			// clear the editor so the command doesn't linger after the
1692			// dialog opens/closes.
1693			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1694				i.ed.Clear()
1695				i.suggest.Reset()
1696				return i.runSlash(ctx, name)
1697			}
1698		case tui.KeyEsc:
1699			i.ed.Clear()
1700			i.suggest.Reset()
1701			return false
1702		}
1703	}
1704
1705	// File suggestions: intercept up/down/tab/enter when the @-popup is visible.
1706	if i.fileSuggest.Active(i.ed.Value()) {
1707		switch k.Kind {
1708		case tui.KeyUp:
1709			i.fileSuggest.Up()
1710			return false
1711		case tui.KeyDown:
1712			i.fileSuggest.Down()
1713			return false
1714		case tui.KeyRight:
1715			// Open selected directory.
1716			i.fileSuggest.Right()
1717			return false
1718		case tui.KeyLeft:
1719			// Go back to parent directory.
1720			i.fileSuggest.Left()
1721			return false
1722		case tui.KeyEnter:
1723			if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
1724				var chip string
1725				if entry.isDir {
1726					chip = "[dir:" + entry.rel + "/]"
1727				} else {
1728					chip = "[file:" + entry.rel + "]"
1729				}
1730				val := i.ed.Value()
1731				if idx := strings.LastIndex(val, "@"); idx >= 0 {
1732					val = val[:idx]
1733				}
1734				i.ed.SetValue(val + chip + " ")
1735				i.fileSuggest.Reset()
1736			}
1737			return false
1738		case tui.KeyEsc:
1739			val := i.ed.Value()
1740			if idx := strings.LastIndex(val, "@"); idx >= 0 {
1741				i.ed.SetValue(val[:idx])
1742			}
1743			i.fileSuggest.Reset()
1744			return false
1745		}
1746	}
1747
1748	if i.handleInputHistoryKey(k) {
1749		return false
1750	}
1751	if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
1752		i.inputHistoryIndex = -1
1753	}
1754
1755	if submit := i.ed.HandleKey(k); submit {
1756		// SubmitValue() expands any [pasted text #N +L lines]
1757		// placeholders back into their bodies; the raw Value()
1758		// is only what the user sees on screen.
1759		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1760		// Expand [file:name] and [dir:name/] chips to full paths.
1761		text = expandFileChips(text, i.cfg.CWD)
1762		if text == "" {
1763			return false
1764		}
1765		i.ed.Clear()
1766		i.inputHistoryIndex = -1
1767		i.suggest.Reset()
1768		i.fileSuggest.Reset()
1769
1770		if looksLikeSlashCommand(text) {
1771			head := text
1772			rest := ""
1773			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1774				head = text[:idx]
1775				rest = strings.TrimSpace(text[idx:])
1776			}
1777			if !isKnownSlashCommand(text) {
1778				// Try extensions before giving up. Extensions register
1779				// commands by bare name (no leading slash); strip it here.
1780				extName := strings.TrimPrefix(head, "/")
1781				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1782					go i.invokeExtensionCommand(ctx, extName, rest)
1783					return false
1784				}
1785				i.mu.Lock()
1786				i.statusErr = "unknown command " + head + " — type /help to see the list"
1787				i.statusOK = ""
1788				i.mu.Unlock()
1789				return false
1790			}
1791			// Slash commands run regardless of busy state. Commands that
1792			// would mutate the transcript or replace the agent (/clear,
1793			// /compact, /logout, /login, /model) cancel the active turn
1794			// first and wait for the goroutine to wind down so they don't
1795			// race with a streaming response. Safe commands (/help,
1796			// /jump, /sessions, /jail, /unjail, /exit) run immediately
1797			// without disturbing the active turn.
1798			if slashCancelsTurn(head) {
1799				i.cancelAndWaitForIdle()
1800			}
1801			return i.runSlash(ctx, text)
1802		}
1803
1804		if i.agent == nil {
1805			i.mu.Lock()
1806			i.statusErr = "not logged in. type /login first."
1807			i.mu.Unlock()
1808			return false
1809		}
1810		// Mirror the user's typed prompt into the paired Telegram
1811		// chat (when the bridge is active) so the Telegram thread
1812		// stays a complete record of the session, not just the half
1813		// that originated on the phone. On a goroutine so the
1814		// network write doesn't delay the local turn.
1815		if i.telegramBridge != nil && i.telegramBridge.Active() {
1816			go i.telegramBridge.OnUserTyped(text)
1817		}
1818		// If a turn is already in flight, queue this prompt instead of
1819		// starting a second one. The drain loop at the end of startTurn
1820		// will pick it up when the current turn finishes.
1821		i.mu.Lock()
1822		if i.busy {
1823			i.queued = append(i.queued, text)
1824			i.mu.Unlock()
1825			i.invalidate()
1826			return false
1827		}
1828		i.mu.Unlock()
1829		i.startTurn(ctx, text)
1830	}
1831	return false
1832}
1833
1834func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
1835	if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
1836		return false
1837	}
1838	// Do not steal normal cursor movement. History browsing can only
1839	// start from an empty editor; once active, Left/Right keep walking
1840	// the ring so repeated presses work even though the editor now
1841	// contains the selected historical prompt.
1842	if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
1843		return false
1844	}
1845	hist := i.inputHistory()
1846	if len(hist) == 0 {
1847		return false
1848	}
1849
1850	if i.inputHistoryIndex < 0 {
1851		// Start just after the newest item so Left lands on the most
1852		// recent user prompt and Right keeps the editor empty.
1853		i.inputHistoryIndex = len(hist)
1854	}
1855
1856	switch k.Kind {
1857	case tui.KeyLeft:
1858		if i.inputHistoryIndex > 0 {
1859			i.inputHistoryIndex--
1860		}
1861	case tui.KeyRight:
1862		if i.inputHistoryIndex < len(hist) {
1863			i.inputHistoryIndex++
1864		}
1865	}
1866
1867	if i.inputHistoryIndex >= len(hist) {
1868		i.ed.Clear()
1869	} else {
1870		i.ed.SetValue(hist[i.inputHistoryIndex])
1871	}
1872	return true
1873}
1874
1875func (i *Interactive) inputHistory() []string {
1876	if i.agent == nil {
1877		return nil
1878	}
1879	msgs := i.agent.Messages()
1880	hist := make([]string, 0, len(msgs))
1881	for _, m := range msgs {
1882		if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
1883			continue
1884		}
1885		text := userMessageText(m)
1886		if strings.TrimSpace(text) == "" {
1887			continue
1888		}
1889		hist = append(hist, text)
1890	}
1891	return hist
1892}
1893
1894func userMessageText(m provider.Message) string {
1895	var sb strings.Builder
1896	for _, c := range m.Content {
1897		if tb, ok := c.(provider.TextBlock); ok {
1898			if sb.Len() > 0 {
1899				sb.WriteByte('\n')
1900			}
1901			sb.WriteString(tb.Text)
1902		}
1903	}
1904	return sb.String()
1905}
1906
1907// invokeExtensionCommand fires an extension-registered slash command
1908// in a background goroutine, awaits the response, and applies the
1909// requested action (prompt / insert / display / noop). Errors and
1910// timeouts surface as a status_err line.
1911
1912func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1913	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1914	if err != nil {
1915		i.mu.Lock()
1916		i.statusErr = "extension /" + name + ": " + err.Error()
1917		i.statusOK = ""
1918		i.mu.Unlock()
1919		i.invalidate()
1920		return
1921	}
1922	if resp.Error != "" {
1923		i.mu.Lock()
1924		i.statusErr = "extension /" + name + ": " + resp.Error
1925		i.statusOK = ""
1926		i.mu.Unlock()
1927		i.invalidate()
1928		return
1929	}
1930	switch resp.Action {
1931	case "open_panel":
1932		if resp.OpenPanel != nil {
1933			extName := name
1934			if i.cfg.Extensions != nil {
1935				if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
1936					extName = owner
1937				}
1938			}
1939			i.OpenPanel(extName, *resp.OpenPanel)
1940		}
1941	case "prompt":
1942		if strings.TrimSpace(resp.Prompt) == "" {
1943			return
1944		}
1945		i.startTurn(i.runCtx, resp.Prompt)
1946	case "insert":
1947		i.ed.Insert(resp.Insert)
1948		i.invalidate()
1949	case "display":
1950		i.appendExtensionNote(name, resp.Display, "info")
1951	case "noop", "":
1952		// nothing
1953	default:
1954		i.mu.Lock()
1955		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1956		i.mu.Unlock()
1957		i.invalidate()
1958	}
1959}
1960
1961// appendExtensionNote renders an extension-originated note in the
1962// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1963// "success" (tool/ok green).
1964func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1965	if msg == "" {
1966		return
1967	}
1968	i.mu.Lock()
1969	defer i.mu.Unlock()
1970	color := i.cfg.Theme.Muted
1971	switch level {
1972	case "warn":
1973		color = i.cfg.Theme.Warning
1974	case "error":
1975		color = i.cfg.Theme.Error
1976	case "success":
1977		color = i.cfg.Theme.Tool
1978	}
1979	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1980	for _, line := range strings.Split(msg, "\n") {
1981		i.statusOK = "" // clear any stale ok
1982		i.statusErr = ""
1983		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1984	}
1985}
1986
1987// HostHooks implementation for the extension manager. The manager
1988// holds an interface, not a concrete *Interactive, so these methods
1989// are the only thing the manager sees.
1990
1991// Notify is the manager's NotifyFromExt entry point.
1992func (i *Interactive) Notify(extName, level, message string) {
1993	i.appendExtensionNote(extName, message, level)
1994	i.invalidate()
1995}
1996
1997// Submit feeds text through the agent loop as if the user had typed it.
1998func (i *Interactive) Submit(text string) {
1999	i.startTurn(i.runCtx, text)
2000}
2001
2002// SubmitOrQueue runs text immediately if the agent is idle, or
2003// appends it to the pending queue if a turn is already in flight.
2004// Used by the telegram bridge (and by the editor submit path) so
2005// both input sources share the same "queue behind an active turn"
2006// semantics. Images are ignored for now — only the text prompt is
2007// forwarded — because the queued-prompt path is text-only; a
2008// follow-up can expand the queue entry to carry images.
2009func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2010	i.mu.Lock()
2011	if i.agent == nil {
2012		i.statusErr = "not logged in. type /login first."
2013		i.mu.Unlock()
2014		i.invalidate()
2015		return
2016	}
2017	if i.busy {
2018		// Queue text only; images are dropped for queued prompts.
2019		i.queued = append(i.queued, text)
2020		i.mu.Unlock()
2021		i.invalidate()
2022		return
2023	}
2024	i.mu.Unlock()
2025	i.startTurnWithImages(i.runCtx, text, images)
2026}
2027
2028// CancelTurn aborts the active turn if one is running. Used by the
2029// telegram bridge when the paired user sends /stop.
2030// ChangelogVersion returns the version string of the changelog
2031// currently shown (or last shown). Used by the dismiss callback
2032// to store the correct version for dev builds.
2033func (i *Interactive) ChangelogVersion() string {
2034	if i.changelogDialog != nil {
2035		return i.changelogDialog.version
2036	}
2037	return ""
2038}
2039
2040func (i *Interactive) CancelTurn() {
2041	i.mu.Lock()
2042	cancel := i.cancelTurn
2043	i.mu.Unlock()
2044	if cancel != nil {
2045		cancel()
2046	}
2047}
2048
2049// Insert places text at the cursor in the editor.
2050func (i *Interactive) Insert(text string) {
2051	i.ed.Insert(text)
2052	i.invalidate()
2053}
2054
2055// Display appends a styled note from extName to the chat without a
2056// model call.
2057func (i *Interactive) Display(extName, text string) {
2058	i.appendExtensionNote(extName, text, "info")
2059	i.invalidate()
2060}
2061
2062func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
2063	i.mu.Lock()
2064	defer i.mu.Unlock()
2065	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
2066	if i.cfg.Extensions != nil {
2067		cols, rows := i.cfg.Terminal.Size()
2068		_ = cols
2069		_ = rows
2070	}
2071	i.invalidate()
2072}
2073
2074func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
2075	i.mu.Lock()
2076	defer i.mu.Unlock()
2077	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2078		i.extPanel.Update(title, lines, footer)
2079		i.invalidate()
2080	}
2081}
2082
2083func (i *Interactive) ClosePanel(extName, panelID string) {
2084	i.mu.Lock()
2085	defer i.mu.Unlock()
2086	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2087		i.extPanel.Close()
2088		i.invalidate()
2089	}
2090}
2091
2092func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2093	parts := strings.Fields(cmd)
2094	switch parts[0] {
2095	case "/exit":
2096		return true
2097	case "/clear":
2098		if i.agent != nil {
2099			i.agent.SetMessages(nil)
2100		}
2101		i.mu.Lock()
2102		i.toolCalls = map[string]*tui.ToolCallView{}
2103		i.toolOrder = nil
2104		i.statusErr = ""
2105		i.statusOK = ""
2106		i.helpBlock = nil
2107		i.parkedTurn = 0
2108		i.parkedTotal = 0
2109		i.scrollOffset = 0
2110		i.extNotes = nil
2111		i.view.InvalidateRenderCache()
2112		i.mu.Unlock()
2113	case "/help":
2114		i.mu.Lock()
2115		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
2116		i.statusErr = ""
2117		i.statusOK = ""
2118		// Pin the viewport to the newest content so the help block,
2119		// which we just appended to the end of the transcript, is
2120		// what the user actually sees.
2121		i.scrollOffset = 0
2122		i.mu.Unlock()
2123	case "/login":
2124		i.dialog.Open(i.cfg.ZotHome)
2125	case "/logout":
2126		if len(parts) >= 2 {
2127			// Explicit target: /logout anthropic | openai | all
2128			i.doLogout(parts[1])
2129			break
2130		}
2131		// No arg: open the picker over whichever providers are
2132		// currently logged in. If nothing's logged in, bail with a
2133		// status line.
2134		i.openLogoutDialog()
2135	case "/model":
2136		if len(parts) >= 2 {
2137			i.applyModelSelection("", parts[1])
2138		} else {
2139			var loggedIn []string
2140			if i.cfg.LoggedInProviders != nil {
2141				loggedIn = i.cfg.LoggedInProviders()
2142			}
2143			i.modelDialog.Open(i.cfg.Model, loggedIn)
2144		}
2145	case "/sessions":
2146		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
2147	case "/jump":
2148		i.openJumpDialog(parts[1:])
2149	case "/btw":
2150		i.openBtwDialog(parts[1:])
2151	case "/skills":
2152		i.openSkillsDialog()
2153	case "/compact":
2154		i.runCompact(ctx, false)
2155	case "/study":
2156		// Canned prompt that tells the agent to read every file
2157		// in the current directory so its later turns have the
2158		// whole project in context. Dispatched through the normal
2159		// queue-or-start path so it behaves identically to
2160		// typing the prompt by hand.
2161		const studyPrompt = "Read and understand everything in the current directory."
2162		i.mu.Lock()
2163		if i.busy {
2164			i.queued = append(i.queued, studyPrompt)
2165			i.mu.Unlock()
2166			i.invalidate()
2167			break
2168		}
2169		i.mu.Unlock()
2170		i.startTurn(ctx, studyPrompt)
2171	case "/jail":
2172		if i.cfg.Sandbox == nil {
2173			i.mu.Lock()
2174			i.statusErr = "sandbox not available in this build"
2175			i.mu.Unlock()
2176			break
2177		}
2178		i.cfg.Sandbox.Lock()
2179		i.mu.Lock()
2180		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
2181		i.statusErr = ""
2182		i.mu.Unlock()
2183	case "/unjail":
2184		if i.cfg.Sandbox == nil {
2185			i.mu.Lock()
2186			i.statusErr = "sandbox not available in this build"
2187			i.mu.Unlock()
2188			break
2189		}
2190		i.cfg.Sandbox.Unlock()
2191		i.mu.Lock()
2192		i.statusOK = "unjailed"
2193		i.statusErr = ""
2194		i.mu.Unlock()
2195	case "/reload-ext":
2196		i.runReloadExt(ctx)
2197	case "/yolo":
2198		i.runYoloOn()
2199	case "/telegram", "/tg":
2200		if len(parts) >= 2 {
2201			i.doTelegram(parts[1])
2202			break
2203		}
2204		i.openTelegramDialog()
2205	case "/session":
2206		if len(parts) >= 2 {
2207			action := parts[1]
2208			arg := ""
2209			if len(parts) >= 3 {
2210				arg = strings.Join(parts[2:], " ")
2211			}
2212			i.doSessionOp(action, arg)
2213			break
2214		}
2215		i.openSessionOpsDialog()
2216	default:
2217		// Last-resort fallback: try the extension manager. Built-in
2218		// cases above always win; this branch only fires for slash
2219		// commands the extension manager registered. Same routing as
2220		// the editor's submit-handler dispatch path so the autocomplete
2221		// "enter on highlighted suggestion" flow also works.
2222		extName := strings.TrimPrefix(parts[0], "/")
2223		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2224			rest := ""
2225			if len(parts) > 1 {
2226				rest = strings.Join(parts[1:], " ")
2227			}
2228			go i.invokeExtensionCommand(ctx, extName, rest)
2229			return false
2230		}
2231		i.mu.Lock()
2232		i.statusErr = "unknown command: " + parts[0]
2233		i.mu.Unlock()
2234	}
2235	return false
2236}
2237
2238// openLogoutDialog shows the provider picker for `/logout` with no
2239// argument. Only providers the user is currently logged into are
2240// listed, plus an "all" entry when more than one is present. If
2241// nothing's logged in, writes a status line instead of opening an
2242// empty dialog.
2243func (i *Interactive) openLogoutDialog() {
2244	if i.cfg.AuthManager == nil {
2245		i.mu.Lock()
2246		i.statusErr = "no auth manager configured"
2247		i.mu.Unlock()
2248		i.invalidate()
2249		return
2250	}
2251	store := i.cfg.AuthManager.Store()
2252	if store == nil {
2253		i.mu.Lock()
2254		i.statusErr = "auth store is not available"
2255		i.mu.Unlock()
2256		i.invalidate()
2257		return
2258	}
2259	creds, err := store.Load()
2260	if err != nil {
2261		i.mu.Lock()
2262		i.statusErr = "read auth store: " + err.Error()
2263		i.mu.Unlock()
2264		i.invalidate()
2265		return
2266	}
2267
2268	var items []logoutItem
2269	for _, p := range []string{"anthropic", "openai"} {
2270		if creds.Has(p) {
2271			method := creds.Method(p)
2272			if method == "oauth" {
2273				method = "subscription"
2274			}
2275			items = append(items, logoutItem{
2276				label:  providerLabel(p),
2277				target: p,
2278				method: method,
2279			})
2280		}
2281	}
2282	if len(items) == 0 {
2283		i.mu.Lock()
2284		i.statusOK = "no credentials stored; already logged out"
2285		i.statusErr = ""
2286		i.mu.Unlock()
2287		i.invalidate()
2288		return
2289	}
2290	if len(items) > 1 {
2291		items = append(items, logoutItem{label: "all", target: "all"})
2292	}
2293
2294	i.logoutDialog.Open(items)
2295	i.invalidate()
2296}
2297
2298// doLogout clears credentials for the given provider (or all providers)
2299// from auth.json. If the active agent was using those credentials, it
2300// is torn down so the user is forced through /login before their next
2301// prompt.
2302//
2303// target: "anthropic" | "openai" | "all"
2304func (i *Interactive) doLogout(target string) {
2305	if i.cfg.AuthManager == nil {
2306		i.mu.Lock()
2307		i.statusErr = "no auth manager configured"
2308		i.mu.Unlock()
2309		return
2310	}
2311	store := i.cfg.AuthManager.Store()
2312	if store == nil {
2313		i.mu.Lock()
2314		i.statusErr = "auth store is not available"
2315		i.mu.Unlock()
2316		return
2317	}
2318
2319	var providers []string
2320	switch target {
2321	case "", "all":
2322		providers = []string{"anthropic", "openai"}
2323	case "anthropic", "openai":
2324		providers = []string{target}
2325	default:
2326		i.mu.Lock()
2327		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
2328		i.mu.Unlock()
2329		return
2330	}
2331
2332	var errs []string
2333	clearedCurrent := false
2334	for _, p := range providers {
2335		if err := store.Clear(p); err != nil {
2336			errs = append(errs, p+": "+err.Error())
2337			continue
2338		}
2339		if p == i.cfg.Provider {
2340			clearedCurrent = true
2341		}
2342	}
2343
2344	i.mu.Lock()
2345	defer i.mu.Unlock()
2346	if len(errs) > 0 {
2347		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
2348		return
2349	}
2350	i.statusErr = ""
2351	if clearedCurrent {
2352		// The running agent was using a credential we just wiped. Drop
2353		// it so prompts can't go out with the stale client, and hint at
2354		// /login.
2355		i.agent = nil
2356		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
2357	} else {
2358		i.statusOK = "logged out of " + strings.Join(providers, ", ")
2359	}
2360}
2361
2362func (i *Interactive) startAPIKeyFlow(provider string) {
2363	url, err := i.cfg.AuthManager.StartAPIKey(provider)
2364	if err != nil {
2365		i.dialog.ShowResult(false, err.Error())
2366		return
2367	}
2368	i.dialog.ShowWaiting(url)
2369}
2370
2371func (i *Interactive) startOAuthFlow(provider string) {
2372	// Always run the manual/copy-code flow in parallel with the local
2373	// callback server so headless environments (docker, SSH) can paste
2374	// the authorization code directly without first pressing 'p'.
2375	_, err := i.cfg.AuthManager.StartOAuth(provider)
2376	if err != nil {
2377		i.dialog.ShowResult(false, err.Error())
2378		return
2379	}
2380	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
2381	if mErr == nil {
2382		i.dialog.ShowWaiting(manualURL)
2383	} else {
2384		i.dialog.ShowResult(false, mErr.Error())
2385	}
2386}
2387
2388func (i *Interactive) startManualOAuthFlow(provider string) {
2389	if i.cfg.AuthManager == nil {
2390		return
2391	}
2392	i.cfg.AuthManager.CancelOAuth()
2393	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
2394	if err != nil {
2395		i.dialog.ShowResult(false, err.Error())
2396		return
2397	}
2398	i.dialog.url = url
2399	i.invalidate()
2400}
2401
2402func (i *Interactive) submitManualOAuthCode(code string) {
2403	if i.cfg.AuthManager == nil {
2404		return
2405	}
2406	go func() {
2407		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
2408			i.dialog.ShowResult(false, err.Error())
2409			i.invalidate()
2410		}
2411	}()
2412}
2413
2414// applyModelSelection switches the active model (and provider, if the
2415// new model belongs to a different one). It rebuilds the underlying
2416// client when needed so the provider wire-protocol matches.
2417// cancelAndWaitForIdle cancels the active turn (if any) and blocks
2418// briefly until the turn goroutine has updated i.busy = false. Used
2419// before destructive slash commands so transcript-mutating work
2420// (/clear, /compact, /logout, /login completion, cross-provider
2421// /model swap) doesn't race with the still-running stream.
2422//
2423// The wait is bounded; if the turn doesn't release within the timeout
2424// we proceed anyway. Worst case is a brief overlap that the agent's
2425// own mutex protects against.
2426func (i *Interactive) cancelAndWaitForIdle() {
2427	i.mu.Lock()
2428	busy := i.busy
2429	cancel := i.cancelTurn
2430	i.mu.Unlock()
2431	if !busy {
2432		return
2433	}
2434	if cancel != nil {
2435		cancel()
2436	}
2437	deadline := time.Now().Add(2 * time.Second)
2438	for time.Now().Before(deadline) {
2439		i.mu.Lock()
2440		done := !i.busy
2441		i.mu.Unlock()
2442		if done {
2443			return
2444		}
2445		time.Sleep(10 * time.Millisecond)
2446	}
2447}
2448
2449// openBtwDialog opens the side-chat overlay with a frozen snapshot
2450// of the current main session. The optional argument is auto-
2451// submitted as the first question, so '/btw does X work?' fires the
2452// model call immediately instead of just opening an empty dialog.
2453func (i *Interactive) openBtwDialog(args []string) {
2454	if i.agent == nil {
2455		i.mu.Lock()
2456		i.statusErr = "not logged in. type /login first."
2457		i.mu.Unlock()
2458		return
2459	}
2460	seed := strings.TrimSpace(strings.Join(args, " "))
2461	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate)
2462	i.invalidate()
2463}
2464
2465// openSkillsDialog opens the skill inspector. The picker reflects
2466// whatever SkillSnapshot returns at call time, so edits to a
2467// SKILL.md made during a session show up on the next /skills.
2468func (i *Interactive) openSkillsDialog() {
2469	var list []*skills.Skill
2470	if i.cfg.SkillSnapshot != nil {
2471		list = i.cfg.SkillSnapshot()
2472	}
2473	i.skillsDialog.Open(list)
2474	i.invalidate()
2475}
2476
2477// openJumpDialog builds a /jump picker from the current transcript.
2478// If the user typed "/jump foo" with a filter and it matches exactly
2479// one turn, jump there directly without showing the dialog.
2480func (i *Interactive) openJumpDialog(args []string) {
2481	if i.view == nil || len(i.view.Messages) == 0 {
2482		i.mu.Lock()
2483		i.statusErr = "nothing to jump to \u2014 the session is empty"
2484		i.mu.Unlock()
2485		return
2486	}
2487	filter := strings.TrimSpace(strings.Join(args, " "))
2488	i.jumpDialog.Open(i.view.Messages, filter)
2489	// Shortcut: with a filter argument that matches exactly one turn,
2490	// jump immediately and skip the picker.
2491	if filter != "" {
2492		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
2493			t := tgts[0]
2494			i.jumpDialog.Close()
2495			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
2496		}
2497	}
2498}
2499
2500// applyJumpSelection scrolls the chat viewport so the user message at
2501// msgIdx is visible at (or near) the top of the chat area. Uses the
2502// anchor slice returned by view.BuildWithAnchors so the mapping from
2503// message index to row is exact, regardless of variable-height tool
2504// blocks above the target.
2505func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
2506	cols := i.lastCols()
2507	chat, anchors := i.view.BuildWithAnchors(cols)
2508	var row int
2509	found := false
2510	for _, a := range anchors {
2511		if a.MessageIdx == msgIdx {
2512			row = a.Row
2513			found = true
2514			break
2515		}
2516	}
2517	if !found {
2518		i.mu.Lock()
2519		i.statusErr = "could not resolve jump target"
2520		i.mu.Unlock()
2521		return
2522	}
2523
2524	chatLen := len(chat)
2525	page := i.chatPage()
2526	if page < 1 {
2527		page = 1
2528	}
2529	// scrollOffset is measured from the bottom of the chat slice, so
2530	// to place `row` at the top of the viewport we want:
2531	//     chatLen - scrollOffset - page == row
2532	// Solve for scrollOffset and clamp to [0, chatLen-page].
2533	offset := chatLen - (row + page)
2534	if offset < 0 {
2535		offset = 0
2536	}
2537	maxOffset := chatLen - page
2538	if maxOffset < 0 {
2539		maxOffset = 0
2540	}
2541	if offset > maxOffset {
2542		offset = maxOffset
2543	}
2544
2545	i.mu.Lock()
2546	i.scrollOffset = offset
2547	i.parkedTurn = turnNo
2548	i.parkedTotal = totalTurnsLocked(i.view.Messages)
2549	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
2550	i.statusErr = ""
2551	i.mu.Unlock()
2552}
2553
2554// totalTurnsLocked counts user messages in the transcript. Caller is
2555// assumed to hold i.mu (the name is a mild reminder; this function
2556// itself doesn't touch shared state beyond the slice it's handed).
2557func totalTurnsLocked(msgs []provider.Message) int {
2558	n := 0
2559	for _, m := range msgs {
2560		if m.Role == provider.RoleUser {
2561			n++
2562		}
2563	}
2564	return n
2565}
2566
2567// applySessionSelection loads the given session via the cli-provided
2568// callback and snaps the viewport to the bottom (the latest message)
2569// so the user lands at the live tail of the resumed conversation.
2570func (i *Interactive) applySessionSelection(path string) {
2571	if i.cfg.LoadSession == nil {
2572		i.mu.Lock()
2573		i.statusErr = "session loading is not wired in this build"
2574		i.mu.Unlock()
2575		return
2576	}
2577	if err := i.cfg.LoadSession(path); err != nil {
2578		i.mu.Lock()
2579		i.statusErr = err.Error()
2580		i.mu.Unlock()
2581		return
2582	}
2583
2584	i.mu.Lock()
2585	i.statusOK = "resumed session: " + path
2586	i.statusErr = ""
2587	i.parkedTurn = 0
2588	i.parkedTotal = 0
2589	i.view.InvalidateRenderCache()
2590	if i.agent != nil {
2591		i.view.Messages = i.agent.Messages()
2592		i.cumUsage = i.agent.Cost()
2593	}
2594	i.mu.Unlock()
2595
2596	i.scrollToBottom()
2597}
2598
2599// scrollToLastTurn parks the viewport at the most recent user turn,
2600// or at the top if the transcript has no user messages. Used after
2601// resume so the user lands looking at where they left off.
2602func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
2603	if len(msgs) == 0 {
2604		i.mu.Lock()
2605		i.scrollOffset = 0
2606		i.mu.Unlock()
2607		return
2608	}
2609	// Find the last user message index.
2610	lastUser := -1
2611	turnNo, totalTurns := 0, 0
2612	for idx, m := range msgs {
2613		if m.Role == provider.RoleUser {
2614			totalTurns++
2615			lastUser = idx
2616		}
2617	}
2618	if lastUser < 0 {
2619		i.mu.Lock()
2620		i.scrollOffset = 0
2621		i.mu.Unlock()
2622		return
2623	}
2624	turnNo = totalTurns
2625
2626	cols := i.lastCols()
2627	chat, anchors := i.view.BuildWithAnchors(cols)
2628	var row int
2629	found := false
2630	for _, a := range anchors {
2631		if a.MessageIdx == lastUser {
2632			row = a.Row
2633			found = true
2634			break
2635		}
2636	}
2637	if !found {
2638		i.mu.Lock()
2639		i.scrollOffset = 0
2640		i.mu.Unlock()
2641		return
2642	}
2643
2644	chatLen := len(chat)
2645	page := i.chatPage()
2646	if page < 1 {
2647		page = 1
2648	}
2649	offset := chatLen - (row + page)
2650	if offset < 0 {
2651		offset = 0
2652	}
2653	maxOffset := chatLen - page
2654	if maxOffset < 0 {
2655		maxOffset = 0
2656	}
2657	if offset > maxOffset {
2658		offset = maxOffset
2659	}
2660
2661	i.mu.Lock()
2662	i.scrollOffset = offset
2663	// Mark the parked-turn footer so the user sees "viewing turn N of
2664	// M - pgdn to catch up" — same affordance as /jump. Tells them at
2665	// a glance that they're looking at history, not the live tail.
2666	if offset > 0 {
2667		i.parkedTurn = turnNo
2668		i.parkedTotal = totalTurns
2669	}
2670	i.mu.Unlock()
2671	i.invalidate()
2672}
2673
2674func (i *Interactive) applyModelSelection(prov, model string) {
2675	if model == "" {
2676		return
2677	}
2678	m, err := provider.FindModel(prov, model)
2679	if err != nil {
2680		i.mu.Lock()
2681		i.statusErr = err.Error()
2682		i.mu.Unlock()
2683		return
2684	}
2685	// Same provider? Just swap the model on the existing agent.
2686	if i.agent != nil && m.Provider == i.cfg.Provider {
2687		i.mu.Lock()
2688		i.cfg.Model = m.ID
2689		i.agent.Model = m.ID
2690		i.statusOK = "model: " + m.ID
2691		i.statusErr = ""
2692		i.mu.Unlock()
2693		if i.cfg.PersistModel != nil {
2694			i.cfg.PersistModel(i.cfg.Provider, m.ID)
2695		}
2696		return
2697	}
2698	// Different provider: rebuild agent (needs credentials for target provider).
2699	if i.cfg.BuildAgentFor == nil {
2700		i.mu.Lock()
2701		i.statusErr = "cannot switch provider: no builder configured"
2702		i.mu.Unlock()
2703		return
2704	}
2705	// Snapshot the current transcript and cumulative usage BEFORE we
2706	// build the replacement agent so we can hand them off. Without
2707	// this the user perceives the entire session as wiped on a
2708	// cross-provider /model swap.
2709	var carryMsgs []provider.Message
2710	var carryCost provider.Usage
2711	if i.agent != nil {
2712		carryMsgs = i.agent.Messages()
2713		carryCost = i.agent.Cost()
2714	}
2715
2716	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
2717	if err != nil {
2718		i.mu.Lock()
2719		i.statusErr = err.Error()
2720		i.mu.Unlock()
2721		return
2722	}
2723
2724	// Replay the transcript and seed the cost on the freshly-built
2725	// agent. Messages travel cleanly between providers because they
2726	// use the same provider.Message shape; tool-call ids are local
2727	// to a turn so cross-provider continuation never confuses the
2728	// new model (it just sees the assistant's reply, no orphan
2729	// tool_use blocks because /model swaps are gated to idle state).
2730	if len(carryMsgs) > 0 {
2731		ag.SetMessages(carryMsgs)
2732	}
2733	ag.SeedCost(carryCost)
2734
2735	i.mu.Lock()
2736	i.agent = ag
2737	i.cfg.Provider = p
2738	i.cfg.Model = md
2739	i.statusOK = "switched to " + p + " / " + md
2740	i.statusErr = ""
2741	// Render cache keys are width+content based, so the new agent's
2742	// identical messages will reuse the existing entries. Nothing
2743	// to invalidate.
2744	i.mu.Unlock()
2745	// The new agent was built off the base tool registry, so any
2746	// dynamically-registered tools (telegram_send_*) need to be
2747	// reattached. applyTelegramTools is a no-op when the bridge is
2748	// idle so the cross-provider path still works on a vanilla setup.
2749	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
2750	if i.cfg.PersistModel != nil {
2751		i.cfg.PersistModel(p, md)
2752	}
2753}
2754
2755func (i *Interactive) handleAuthEvent(ev auth.Event) {
2756	switch ev.Kind {
2757	case "started":
2758		i.dialog.ShowWaiting(ev.URL)
2759	case "browser_open":
2760		// no-op
2761	case "error":
2762		i.dialog.ShowResult(false, ev.Message)
2763	case "success":
2764		// Rebuild the agent with the fresh credential.
2765		ag, prov, model, err := i.cfg.BuildAgent()
2766		if err != nil {
2767			i.dialog.ShowResult(false, err.Error())
2768			return
2769		}
2770		i.mu.Lock()
2771		i.agent = ag
2772		i.cfg.Provider = prov
2773		i.cfg.Model = model
2774		i.statusErr = ""
2775		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
2776		i.mu.Unlock()
2777		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
2778		i.dialog.ShowResult(true, "")
2779	}
2780}
2781
2782// runCompact invokes core.Agent.Compact and reflects the progress in
2783// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
2784// cancel via the same cancelTurn channel used for normal turns.
2785//
2786// When auto is true the spinner message is pinned to "condensing
2787// history" and the status bar surfaces "(auto)" next to the context
2788// percentage so it's obvious the system triggered this, not the user.
2789func (i *Interactive) runCompact(parent context.Context, auto bool) {
2790	if i.agent == nil {
2791		i.mu.Lock()
2792		i.statusErr = "not logged in. type /login first."
2793		i.mu.Unlock()
2794		return
2795	}
2796	ctx, cancel := context.WithCancel(parent)
2797	i.mu.Lock()
2798	i.busy = true
2799	if auto {
2800		i.spin.StartFixed("condensing history")
2801		i.autoCompacting = true
2802	} else {
2803		i.spin.StartFixed("compacting")
2804	}
2805	i.cancelTurn = cancel
2806	i.statusErr = ""
2807	i.statusOK = ""
2808	// Do NOT set streamOn: the summary text should not be visible
2809	// in the chat while compacting. The user just sees the spinner
2810	// and can keep typing / queue prompts.
2811	i.scrollOffset = 0
2812	i.helpBlock = nil
2813	i.mu.Unlock()
2814	i.invalidate()
2815
2816	go func() {
2817		// Sink discards deltas — we don't stream the summary to the UI.
2818		sink := func(delta string) {}
2819		summary, err := i.agent.Compact(ctx, 4, sink)
2820		_ = summary
2821		i.mu.Lock()
2822		i.busy = false
2823		i.resetStreamingStateLocked()
2824		i.cancelTurn = nil
2825		i.autoCompacting = false
2826
2827		// Drain the queue: if the user typed a prompt while compacting,
2828		// fire it now that the transcript is clean.
2829		var next string
2830		var hasNext bool
2831
2832		switch {
2833		case err != nil && ctx.Err() != nil:
2834			i.statusErr = ""
2835			if auto {
2836				i.statusOK = "auto-condense cancelled"
2837			} else {
2838				i.statusOK = "compaction cancelled"
2839			}
2840			i.queued = nil // drop queue on cancel
2841		case err != nil:
2842			i.statusErr = "compaction failed: " + err.Error()
2843			i.statusOK = ""
2844			i.queued = nil // drop queue on error
2845		default:
2846			i.statusErr = ""
2847			// Read token count from the compaction message meta.
2848			tokens := ""
2849			msgs := i.agent.Messages()
2850			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
2851				tokens = msgs[0].Meta["tokens_before"]
2852			}
2853			if tokens != "" {
2854				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
2855			} else {
2856				i.statusOK = "compacted (ctrl+o to expand)"
2857			}
2858			i.lastCtxInput = 0
2859			i.toolCalls = map[string]*tui.ToolCallView{}
2860			i.toolOrder = nil
2861			i.view.InvalidateRenderCache()
2862			// Pop queued prompt if any.
2863			if len(i.queued) > 0 {
2864				next, i.queued = i.queued[0], i.queued[1:]
2865				hasNext = true
2866			}
2867		}
2868		i.mu.Unlock()
2869		i.invalidate()
2870
2871		if hasNext {
2872			p := i.runCtx
2873			if p == nil {
2874				p = context.Background()
2875			}
2876			i.startTurn(p, next)
2877		}
2878	}()
2879}
2880
2881func (i *Interactive) startTurn(parent context.Context, prompt string) {
2882	i.startTurnWithImages(parent, prompt, nil)
2883}
2884
2885func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
2886	if i.agent == nil {
2887		return
2888	}
2889	ctx, cancel := context.WithCancel(parent)
2890	i.mu.Lock()
2891	i.busy = true
2892	i.spin.Start()
2893	i.cancelTurn = cancel
2894	i.statusErr = ""
2895	i.statusOK = ""
2896	i.streaming.Reset()
2897	i.streamOn = true
2898	i.toolCalls = map[string]*tui.ToolCallView{}
2899	i.toolOrder = nil
2900	i.scrollOffset = 0 // jump back to the bottom on new turn
2901	i.parkedTurn = 0   // starting a turn clears the /jump parked state
2902	i.parkedTotal = 0
2903	i.helpBlock = nil // hide the help block once the user asks something
2904	i.mu.Unlock()
2905	i.invalidate()
2906
2907	sink := func(ev core.AgentEvent) {
2908		i.handleEvent(ev)
2909		i.invalidate()
2910	}
2911
2912	go func() {
2913		err := i.agent.Prompt(ctx, prompt, images, sink)
2914		i.mu.Lock()
2915		i.busy = false
2916		// Don't touch streamPending / streamFlushPending here — the
2917		// pacer may still be draining the final deltas and needs to
2918		// paint them even though Prompt has returned. It will reset
2919		// streamOn on its own once the buffer empties.
2920		if len(i.streamPending) == 0 {
2921			i.streamOn = false
2922		}
2923		i.cancelTurn = nil
2924		if err != nil && ctx.Err() == nil {
2925			i.statusErr = err.Error()
2926		}
2927		// Persist the assistant's reply (and every tool row before
2928		// it) to the session file while the turn memory is hot.
2929		// Without this, WriteNewTranscript only fires at zot exit,
2930		// meaning a crash or ungraceful kill drops the whole
2931		// conversation. FlushSession is idempotent (it advances the
2932		// baseline so subsequent flushes only write new rows).
2933		flush := i.cfg.FlushSession
2934		i.mu.Unlock()
2935		if flush != nil {
2936			flush()
2937		}
2938		i.mu.Lock()
2939		// Pop the next queued message, if any, and relaunch.
2940		var next string
2941		var hasNext bool
2942		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
2943			next, i.queued = i.queued[0], i.queued[1:]
2944			hasNext = true
2945		}
2946		// If the turn was cancelled or errored, drop the queue so the
2947		// user isn't bombarded with stale messages after an interrupt.
2948		if ctx.Err() != nil || err != nil {
2949			i.queued = nil
2950		}
2951		// Decide whether the next thing to do is an auto-compaction.
2952		// Only fires when the turn completed cleanly AND the queue is
2953		// empty (otherwise a queued message would race the condense).
2954		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
2955		i.mu.Unlock()
2956		i.invalidate()
2957		parent := i.runCtx
2958		if parent == nil {
2959			parent = context.Background()
2960		}
2961		switch {
2962		case hasNext:
2963			i.startTurn(parent, next)
2964		case shouldAutoCompact:
2965			i.runCompact(parent, true)
2966		}
2967	}()
2968}
2969
2970// autoCompactThreshold is the context-window fraction at which the
2971// agent will auto-compact after a turn ends. 0.85 leaves enough
2972// headroom for one more user prompt + response before we bump the
2973// hard limit.
2974const autoCompactThreshold = 0.85
2975
2976// shouldAutoCompactLocked reports whether the last turn pushed context
2977// usage past the auto-compact threshold. Must be called with i.mu
2978// held; it reads lastCtxInput and the current model's context window.
2979func (i *Interactive) shouldAutoCompactLocked() bool {
2980	if i.agent == nil {
2981		return false
2982	}
2983	if i.autoCompacting {
2984		return false
2985	}
2986	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
2987	if err != nil || m.ContextWindow <= 0 {
2988		return false
2989	}
2990	if i.lastCtxInput <= 0 {
2991		return false
2992	}
2993	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
2994}
2995
2996func (i *Interactive) handleEvent(ev core.AgentEvent) {
2997	i.mu.Lock()
2998	defer i.mu.Unlock()
2999	switch e := ev.(type) {
3000	case core.EvAssistantStart:
3001		// Fires at the top of every oneTurn, including follow-up
3002		// turns after tool use. Without this, the streaming buffer
3003		// is still marked off from the previous assistant message
3004		// and the final summary text pops in all at once instead
3005		// of typewriter-streaming delta by delta.
3006		i.streaming.Reset()
3007		i.streamPending = i.streamPending[:0]
3008		i.streamFlushPending = false
3009		i.streamOn = true
3010		// Clear the live tool-call overlay. Any tools from the
3011		// previous round are now fully folded into the transcript
3012		// (assistant tool_use block + tool role message with the
3013		// result), so keeping them in the overlay would duplicate
3014		// them in the view — once inside the finalised transcript
3015		// and once below the streaming block, with the streaming
3016		// summary sandwiched in between. The next EvToolUseStart
3017		// will populate fresh entries for this turn's tools.
3018		i.toolCalls = map[string]*tui.ToolCallView{}
3019		i.toolOrder = nil
3020	case core.EvTextDelta:
3021		// Buffer into streamPending; the paintPace ticker drains
3022		// it into i.streaming a few runes at a time for a smooth
3023		// typewriter effect independent of upstream chunk size.
3024		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
3025		i.streamOn = true
3026	case core.EvAssistantMessage:
3027		// OnAssistant + telegram mirroring always fire on message
3028		// arrival — they read the FINAL message content, which is
3029		// complete regardless of what's still in the pacer.
3030		i.assistantMessageSideEffects(e.Message)
3031		// If the pacer still has characters to drain, keep streamOn
3032		// true and mark flush pending; the paintPace ticker will
3033		// drain the remainder and reset streaming state when done.
3034		// Otherwise (rare: full-replay sessions, abort paths) clear
3035		// synchronously so a later render doesn't show stale text.
3036		if len(i.streamPending) > 0 {
3037			i.streamFlushPending = true
3038			return
3039		}
3040		i.resetStreamingStateLocked()
3041		// Force full repaint: assistant text committed, layout shifts.
3042		if i.rend != nil {
3043			i.rend.Invalidate()
3044		}
3045	case core.EvToolUseStart:
3046		// Live streaming: pre-create the view so the user sees the
3047		// tool call being composed in real time. Any subsequent
3048		// EvToolCall for the same ID updates the same struct (the
3049		// final parsed args + name are already known here).
3050		if _, exists := i.toolCalls[e.ID]; !exists {
3051			i.toolCalls[e.ID] = &tui.ToolCallView{
3052				ID:        e.ID,
3053				Name:      e.Name,
3054				Streaming: true,
3055			}
3056			i.toolOrder = append(i.toolOrder, e.ID)
3057		}
3058	case core.EvToolUseArgs:
3059		if tc, ok := i.toolCalls[e.ID]; ok {
3060			tc.RawJSONBuf += e.Delta
3061			// Refresh the live path as soon as it parses; used in
3062			// the header (write /Users/pat/Desktop/demo.ts)
3063			// while the content is still streaming.
3064			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
3065				tc.LivePath = p
3066			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
3067				tc.LivePath = p
3068			}
3069		}
3070	case core.EvToolUseEnd:
3071		if tc, ok := i.toolCalls[e.ID]; ok {
3072			tc.Streaming = false
3073		}
3074	case core.EvToolCall:
3075		// If we already pre-created the view during streaming, just
3076		// refresh the final Args summary. Otherwise create a new one
3077		// (non-streaming providers or legacy paths).
3078		if tc, ok := i.toolCalls[e.ID]; ok {
3079			tc.Args = tui.ShortArgs(e.Name, e.Args)
3080			tc.Streaming = false
3081		} else {
3082			i.toolCalls[e.ID] = &tui.ToolCallView{
3083				ID:   e.ID,
3084				Name: e.Name,
3085				Args: tui.ShortArgs(e.Name, e.Args),
3086			}
3087			i.toolOrder = append(i.toolOrder, e.ID)
3088		}
3089	case core.EvToolResult:
3090		if tc, ok := i.toolCalls[e.ID]; ok {
3091			tc.Done = true
3092			tc.Error = e.Result.IsError
3093			var text strings.Builder
3094			for _, c := range e.Result.Content {
3095				if tb, ok := c.(provider.TextBlock); ok {
3096					if text.Len() > 0 {
3097						text.WriteString("\n")
3098					}
3099					text.WriteString(tb.Text)
3100				}
3101			}
3102			tc.Result = text.String()
3103		}
3104		// Force full repaint: tool completion shifts the layout
3105		// (collapsed body replaces streaming) and the diff renderer
3106		// may leave stale code fragments on status bar rows.
3107		if i.rend != nil {
3108			i.rend.Invalidate()
3109		}
3110		if i.cfg.OnToolResult != nil {
3111			i.cfg.OnToolResult(e.ID, e.Result)
3112		}
3113	case core.EvUsage:
3114		i.cumUsage = e.Cumulative
3115		if e.Usage.InputTokens > 0 {
3116			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
3117		}
3118	case core.EvTurnEnd:
3119		// Force full repaint: the turn ending shifts layout (streaming
3120		// buffer flushed, status bar updates) and the diff renderer
3121		// may leave stale content on reused rows.
3122		if i.rend != nil {
3123			i.rend.Invalidate()
3124		}
3125		if e.Stop == provider.StopAborted {
3126			i.resetStreamingStateLocked()
3127			i.statusErr = ""
3128			i.statusOK = "cancelled"
3129			return
3130		}
3131		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
3132			i.statusErr = e.Err.Error()
3133		}
3134	}
3135}
3136
3137// Agent returns the current agent, if any. Used by cli.go to flush the
3138// final transcript to the session file.
3139func (i *Interactive) Agent() *core.Agent {
3140	i.mu.Lock()
3141	defer i.mu.Unlock()
3142	return i.agent
3143}
3144
3145// silence unused import in some build configs
3146var _ = fmt.Sprintf
3147
3148// runReloadExt triggers a live reload of every extension (discovered
3149// + explicit). Runs on a goroutine so the TUI stays responsive; the
3150// Manager.Reload takes a couple of hundred ms to shut down subprocs
3151// and respawn them. Shows a status line throughout.
3152func (i *Interactive) runReloadExt(ctx context.Context) {
3153	if i.cfg.Extensions == nil {
3154		i.mu.Lock()
3155		i.statusErr = "no extension manager in this build"
3156		i.mu.Unlock()
3157		i.invalidate()
3158		return
3159	}
3160	i.mu.Lock()
3161	i.statusOK = "reloading extensions…"
3162	i.statusErr = ""
3163	i.mu.Unlock()
3164	i.invalidate()
3165
3166	go func() {
3167		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
3168		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
3169		if len(stats.Errors) > 0 {
3170			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
3171		}
3172		i.mu.Lock()
3173		i.statusOK = msg
3174		i.statusErr = ""
3175		i.mu.Unlock()
3176		i.invalidate()
3177	}()
3178}
3179
3180// Confirm implements core.Confirmer. The agent goroutine calls
3181// this synchronously before every tool invocation when --no-yolo is
3182// active. We push the request onto the confirmDialog queue, trigger
3183// a redraw, and block the caller until the user answers.
3184//
3185// If the session is cancelled or the TUI exits mid-prompt, any
3186// pending request is refused via CancelAll so the agent doesn't
3187// deadlock.
3188func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
3189	resp := make(chan core.ConfirmDecision, 1)
3190	i.confirmDialog.Enqueue(&confirmRequest{
3191		toolName: toolName,
3192		preview:  preview,
3193		resp:     resp,
3194	})
3195	i.invalidate()
3196	return <-resp
3197}
3198
3199// runYoloOn disables --no-yolo for the rest of the session. Tool
3200// calls run without prompting after this; there's intentionally no
3201// way to re-enable gating mid-session, if the user wants that back
3202// they can exit and restart with --no-yolo.
3203func (i *Interactive) runYoloOn() {
3204	if i.cfg.ConfirmGate == nil {
3205		i.mu.Lock()
3206		i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
3207		i.statusErr = ""
3208		i.mu.Unlock()
3209		i.invalidate()
3210		return
3211	}
3212	i.cfg.ConfirmGate.AllowAll()
3213	// Also auto-allow any currently pending confirmation so the
3214	// agent doesn't deadlock if /yolo is typed while a prompt is
3215	// open.
3216	i.confirmDialog.AllowAllPending()
3217	i.mu.Lock()
3218	i.statusOK = "yolo engaged: no more tool-call confirmations this session"
3219	i.statusErr = ""
3220	i.mu.Unlock()
3221	i.invalidate()
3222}
3223
3224// openTelegramDialog shows the picker for `/telegram` with no arg.
3225// Items depend on current state: disconnect + status when running,
3226// connect + status when stopped.
3227func (i *Interactive) openTelegramDialog() {
3228	items := i.telegramMenuItems()
3229	if len(items) == 0 {
3230		i.mu.Lock()
3231		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
3232		i.mu.Unlock()
3233		i.invalidate()
3234		return
3235	}
3236	i.telegramDialog.Open(items)
3237	i.invalidate()
3238}
3239
3240// telegramMenuItems builds the dialog entries for the current
3241// bridge state. Returns empty when no bot.json exists so the
3242// caller can show a helpful status line instead of an empty menu.
3243func (i *Interactive) telegramMenuItems() []telegramItem {
3244	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3245	if err != nil || cfg.BotToken == "" {
3246		return nil
3247	}
3248	var items []telegramItem
3249	if i.telegramBridge != nil && i.telegramBridge.Active() {
3250		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
3251		st := i.telegramBridge.State()
3252		hint := "active"
3253		if st.Username != "" {
3254			hint += " as @" + st.Username
3255		}
3256		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
3257	} else {
3258		label := "connect"
3259		hint := "start mirroring dms into this session"
3260		if cfg.AllowedUserID == 0 {
3261			hint = "awaiting pairing (send /start to the bot once connected)"
3262		}
3263		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
3264		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
3265	}
3266	return items
3267}
3268
3269// doTelegram dispatches one of the three explicit actions. Called
3270// from /telegram <action> or after the picker selects a row.
3271func (i *Interactive) doTelegram(action string) {
3272	switch action {
3273	case "connect":
3274		i.telegramConnect()
3275	case "disconnect":
3276		i.telegramDisconnect()
3277	case "status":
3278		i.telegramStatus()
3279	default:
3280		i.mu.Lock()
3281		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
3282		i.mu.Unlock()
3283		i.invalidate()
3284	}
3285}
3286
3287// telegramConnect starts the bridge. Refuses if it's already
3288// running or if the on-disk bot.json is missing a token.
3289func (i *Interactive) telegramConnect() {
3290	if i.telegramBridge != nil && i.telegramBridge.Active() {
3291		i.mu.Lock()
3292		i.statusOK = "telegram already connected"
3293		i.statusErr = ""
3294		i.mu.Unlock()
3295		i.invalidate()
3296		return
3297	}
3298	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3299	if err != nil {
3300		i.mu.Lock()
3301		i.statusErr = "telegram: " + err.Error()
3302		i.mu.Unlock()
3303		i.invalidate()
3304		return
3305	}
3306	if cfg.BotToken == "" {
3307		i.mu.Lock()
3308		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
3309		i.mu.Unlock()
3310		i.invalidate()
3311		return
3312	}
3313	// Refuse to start when a background daemon is already polling
3314	// the same bot. Two concurrent long-poll consumers race each
3315	// update and one always loses, so DMs get half-delivered. The
3316	// user can `zot telegram-bot stop` first, then /telegram connect.
3317	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
3318		i.mu.Lock()
3319		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
3320		i.mu.Unlock()
3321		i.invalidate()
3322		return
3323	}
3324	i.telegramBridge = &telegram.Bridge{
3325		Client: telegram.NewClient(cfg.BotToken),
3326		Config: cfg,
3327		Save: func(next telegram.Config) error {
3328			return telegram.SaveConfig(i.cfg.ZotHome, next)
3329		},
3330		Host: &telegramHost{iv: i},
3331	}
3332	if err := i.telegramBridge.Start(i.runCtx); err != nil {
3333		i.telegramBridge = nil
3334		i.mu.Lock()
3335		i.statusErr = "telegram connect failed: " + err.Error()
3336		i.mu.Unlock()
3337		i.invalidate()
3338		return
3339	}
3340	i.applyTelegramTools(true)
3341	state := i.telegramBridge.State()
3342	label := "telegram connected"
3343	if state.Username != "" {
3344		label += " as @" + state.Username
3345	}
3346	if state.PairedID == 0 {
3347		label += " — send /start to the bot from your phone to claim it"
3348	}
3349	i.mu.Lock()
3350	i.statusOK = label
3351	i.statusErr = ""
3352	i.mu.Unlock()
3353	i.invalidate()
3354}
3355
3356// telegramDisconnect stops the bridge. No-op when already stopped.
3357func (i *Interactive) telegramDisconnect() {
3358	if i.telegramBridge == nil || !i.telegramBridge.Active() {
3359		i.mu.Lock()
3360		i.statusOK = "telegram already disconnected"
3361		i.statusErr = ""
3362		i.mu.Unlock()
3363		i.invalidate()
3364		return
3365	}
3366	i.telegramBridge.Stop()
3367	i.applyTelegramTools(false)
3368	i.mu.Lock()
3369	i.statusOK = "telegram disconnected"
3370	i.statusErr = ""
3371	i.mu.Unlock()
3372	i.invalidate()
3373}
3374
3375// telegramSenderAdapter wraps the bridge so the tools package can
3376// drive it without importing telegram directly. The Active() check
3377// is forwarded to the bridge so the tool can fail clearly with a
3378// model-readable error when the user disconnected mid-turn.
3379type telegramSenderAdapter struct {
3380	bridge *telegram.Bridge
3381}
3382
3383func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
3384	if a.bridge == nil {
3385		return fmt.Errorf("telegram bridge is not connected")
3386	}
3387	return a.bridge.SendImage(ctx, path, caption)
3388}
3389
3390func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
3391	if a.bridge == nil {
3392		return fmt.Errorf("telegram bridge is not connected")
3393	}
3394	return a.bridge.SendDocument(ctx, path, caption)
3395}
3396
3397func (a telegramSenderAdapter) Active() bool {
3398	return a.bridge != nil && a.bridge.Active()
3399}
3400
3401// applyTelegramTools registers (active=true) or removes (active=false)
3402// the telegram_send_image and telegram_send_file tools on the running
3403// agent so the model only sees them while the bridge is connected.
3404// Snapshots and mutates the live tool registry so any extension or
3405// /reload-ext additions made while Telegram is connected survive a
3406// later /telegram disconnect (we only add or strip the two telegram
3407// entries, never the rest).
3408func (i *Interactive) applyTelegramTools(active bool) {
3409	if i.agent == nil {
3410		return
3411	}
3412	current := i.agent.Tools
3413	next := core.Registry{}
3414	for name, t := range current {
3415		if name == "telegram_send_image" || name == "telegram_send_file" {
3416			continue
3417		}
3418		next[name] = t
3419	}
3420	if active {
3421		sender := telegramSenderAdapter{bridge: i.telegramBridge}
3422		next["telegram_send_image"] = &tools.TelegramSendImageTool{
3423			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3424		}
3425		next["telegram_send_file"] = &tools.TelegramSendFileTool{
3426			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3427		}
3428	}
3429	i.agent.SetTools(next)
3430}
3431
3432// telegramStatus writes a one-liner describing the bridge state.
3433// Reports on both the in-tui bridge and the background daemon so
3434// the user isn't confused when the daemon owns the poll loop.
3435func (i *Interactive) telegramStatus() {
3436	var msg string
3437	if i.telegramBridge != nil && i.telegramBridge.Active() {
3438		s := i.telegramBridge.State()
3439		msg = "telegram: connected (tui bridge)"
3440		if s.Username != "" {
3441			msg += " as @" + s.Username
3442		}
3443		if s.PairedID != 0 {
3444			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
3445		} else {
3446			msg += " - awaiting pairing"
3447		}
3448	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
3449		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
3450	} else {
3451		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
3452		if cfg.BotToken == "" {
3453			msg = "telegram: not configured. run `zot telegram-bot setup` first."
3454		} else {
3455			msg = "telegram: disconnected"
3456			if cfg.BotUsername != "" {
3457				msg += " (@" + cfg.BotUsername + " ready to connect)"
3458			}
3459		}
3460	}
3461	i.mu.Lock()
3462	i.statusOK = msg
3463	i.statusErr = ""
3464	i.mu.Unlock()
3465	i.invalidate()
3466}
3467
3468// telegramHost adapts *Interactive to telegram.Host so the bridge
3469// can call back into the TUI without importing modes directly.
3470type telegramHost struct{ iv *Interactive }
3471
3472func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
3473	h.iv.SubmitOrQueue(prompt, images)
3474}
3475
3476func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
3477
3478func (h *telegramHost) Notify(level, message string) {
3479	h.iv.mu.Lock()
3480	switch level {
3481	case "error", "warn":
3482		h.iv.statusErr = message
3483		h.iv.statusOK = ""
3484	default:
3485		h.iv.statusOK = message
3486		h.iv.statusErr = ""
3487	}
3488	h.iv.mu.Unlock()
3489	h.iv.invalidate()
3490}
3491
3492// openSessionOpsDialog shows the picker for `/session` with no arg.
3493// Always offers export, import, fork, tree; the handlers bail with
3494// a clear status message when the precondition isn't met (empty
3495// transcript on fork; no parent/siblings on tree).
3496func (i *Interactive) openSessionOpsDialog() {
3497	items := []sessionOpsItem{
3498		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
3499		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
3500		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
3501		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
3502	}
3503	i.sessionOpsDialog.Open(items)
3504	i.invalidate()
3505}
3506
3507// doSessionOp dispatches export, import, fork, or tree. arg is the
3508// optional positional argument from e.g. /session export <path>
3509// or /session import <path>; fork and tree ignore it.
3510func (i *Interactive) doSessionOp(action, arg string) {
3511	switch action {
3512	case "export":
3513		i.doSessionExport(arg)
3514	case "import":
3515		i.doSessionImport(arg)
3516	case "fork":
3517		i.doSessionFork()
3518	case "tree":
3519		i.doSessionTree()
3520	default:
3521		i.mu.Lock()
3522		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
3523		i.mu.Unlock()
3524		i.invalidate()
3525	}
3526}
3527
3528// doSessionExport writes the live session file to destination path
3529// dst. When dst is empty we default to ~/Downloads (falling back to
3530// the user's home directory if it doesn't exist). The helper
3531// expands a leading `~` and creates any missing parent directories.
3532func (i *Interactive) doSessionExport(dst string) {
3533	if i.cfg.CurrentSessionPath == nil {
3534		i.mu.Lock()
3535		i.statusErr = "export: no session is active (running with --no-session?)"
3536		i.mu.Unlock()
3537		i.invalidate()
3538		return
3539	}
3540	src := i.cfg.CurrentSessionPath()
3541	if src == "" {
3542		i.mu.Lock()
3543		i.statusErr = "export: no session is active (running with --no-session?)"
3544		i.mu.Unlock()
3545		i.invalidate()
3546		return
3547	}
3548	// Persist any in-memory agent messages to the session file so
3549	// the export carries the full conversation. Without this, the
3550	// default lazy-flush-at-exit strategy leaves most of a running
3551	// session unwritten and the export ends up with only the meta.
3552	if i.cfg.FlushSession != nil {
3553		i.cfg.FlushSession()
3554	}
3555	dst = unquotePath(dst)
3556	if dst == "" {
3557		dst = defaultExportDir()
3558	} else {
3559		dst = expandTilde(dst)
3560	}
3561	out, err := core.ExportSession(src, dst)
3562	if err != nil {
3563		i.mu.Lock()
3564		i.statusErr = "export: " + err.Error()
3565		i.mu.Unlock()
3566		i.invalidate()
3567		return
3568	}
3569	i.mu.Lock()
3570	i.statusOK = "exported session to " + friendlyPath(out)
3571	i.statusErr = ""
3572	i.mu.Unlock()
3573	i.invalidate()
3574}
3575
3576// doSessionImport copies the .zotsession file at src into the
3577// running cwd's sessions directory and loads it as the active
3578// session, same as `/sessions` -> pick. When src is empty we ask
3579// the user to pass a path (no usable default here).
3580func (i *Interactive) doSessionImport(src string) {
3581	src = unquotePath(src)
3582	if src == "" {
3583		i.mu.Lock()
3584		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
3585		i.mu.Unlock()
3586		i.invalidate()
3587		return
3588	}
3589	src = expandTilde(src)
3590	if _, err := os.Stat(src); err != nil {
3591		i.mu.Lock()
3592		i.statusErr = "import: " + err.Error()
3593		i.mu.Unlock()
3594		i.invalidate()
3595		return
3596	}
3597	newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
3598	if err != nil {
3599		i.mu.Lock()
3600		i.statusErr = "import: " + err.Error()
3601		i.mu.Unlock()
3602		i.invalidate()
3603		return
3604	}
3605	if i.cfg.LoadSession == nil {
3606		i.mu.Lock()
3607		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
3608		i.statusErr = ""
3609		i.mu.Unlock()
3610		i.invalidate()
3611		return
3612	}
3613	if err := i.cfg.LoadSession(newPath); err != nil {
3614		i.mu.Lock()
3615		i.statusErr = "import: load failed: " + err.Error()
3616		i.mu.Unlock()
3617		i.invalidate()
3618		return
3619	}
3620	i.mu.Lock()
3621	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
3622	i.statusErr = ""
3623	i.mu.Unlock()
3624	i.invalidate()
3625}
3626
3627// defaultExportDir returns ~/Downloads when it exists, or ~ as a
3628// fallback, or /tmp on exotic machines with no home dir.
3629func defaultExportDir() string {
3630	home, err := os.UserHomeDir()
3631	if err != nil || home == "" {
3632		return os.TempDir()
3633	}
3634	downloads := filepath.Join(home, "Downloads")
3635	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
3636		return downloads
3637	}
3638	return home
3639}
3640
3641// expandTilde turns a leading ~ into the user's home directory.
3642// Returns the input unchanged when there's no tilde or no home.
3643func expandTilde(p string) string {
3644	if p == "" || p[0] != '~' {
3645		return p
3646	}
3647	home, err := os.UserHomeDir()
3648	if err != nil || home == "" {
3649		return p
3650	}
3651	if len(p) == 1 {
3652		return home
3653	}
3654	if p[1] == '/' || p[1] == filepath.Separator {
3655		return filepath.Join(home, p[2:])
3656	}
3657	return p
3658}
3659
3660// unquotePath strips a matching pair of surrounding single or
3661// double quotes. Drag-drop paste in the tui auto-quotes dropped
3662// file paths so the shell-like `/session import 'foo bar.zs'`
3663// stays well-formed; when the TUI's own slash handler consumes
3664// the arg, we want the raw path back.
3665func unquotePath(p string) string {
3666	p = strings.TrimSpace(p)
3667	if len(p) >= 2 {
3668		first := p[0]
3669		last := p[len(p)-1]
3670		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
3671			p = p[1 : len(p)-1]
3672		}
3673	}
3674	return p
3675}
3676
3677// friendlyPath collapses the user's home directory to a leading ~
3678// so status messages read cleanly. Falls back to the raw path when
3679// the home dir is unknown.
3680func friendlyPath(p string) string {
3681	home, err := os.UserHomeDir()
3682	if err != nil || home == "" {
3683		return p
3684	}
3685	if strings.HasPrefix(p, home+string(filepath.Separator)) {
3686		return "~" + p[len(home):]
3687	}
3688	return p
3689}
3690
3691// doSessionFork opens the /jump turn picker in "fork mode". The
3692// next selection branches the current session at that user turn
3693// instead of scrolling the viewport to it.
3694func (i *Interactive) doSessionFork() {
3695	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
3696		i.mu.Lock()
3697		i.statusErr = "fork: no session is active (running with --no-session?)"
3698		i.mu.Unlock()
3699		i.invalidate()
3700		return
3701	}
3702	msgs := []provider.Message{}
3703	if i.agent != nil {
3704		msgs = i.agent.Messages()
3705	}
3706	if len(msgs) == 0 {
3707		i.mu.Lock()
3708		i.statusErr = "fork: transcript is empty; nothing to fork from"
3709		i.mu.Unlock()
3710		i.invalidate()
3711		return
3712	}
3713	i.pendingFork = true
3714	i.jumpDialog.Open(msgs, "")
3715	i.invalidate()
3716}
3717
3718// doSessionTree shows the branch topology picker for this cwd.
3719// Pick an entry to switch into it (same semantics as /sessions
3720// picking a past session, but with the parent/child indentation).
3721func (i *Interactive) doSessionTree() {
3722	if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
3723		i.mu.Lock()
3724		i.statusErr = "tree: session storage not configured"
3725		i.mu.Unlock()
3726		i.invalidate()
3727		return
3728	}
3729	// Flush the running agent's transcript first so its message
3730	// count + latest preview are accurate in the tree.
3731	if i.cfg.FlushSession != nil {
3732		i.cfg.FlushSession()
3733	}
3734	roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
3735	if len(roots) == 0 {
3736		i.mu.Lock()
3737		i.statusErr = "tree: no sessions in this directory yet"
3738		i.mu.Unlock()
3739		i.invalidate()
3740		return
3741	}
3742	current := ""
3743	if i.cfg.CurrentSessionPath != nil {
3744		current = i.cfg.CurrentSessionPath()
3745	}
3746	if !i.sessionTreeDialog.Open(roots, current) {
3747		i.mu.Lock()
3748		i.statusErr = "tree: no branches to show"
3749		i.mu.Unlock()
3750	}
3751	i.invalidate()
3752}
3753
3754// applySessionTreeSelection switches the running agent to the
3755// session file at path. Thin wrapper around LoadSession that also
3756// writes a status line.
3757func (i *Interactive) applySessionTreeSelection(path string) {
3758	if i.cfg.LoadSession == nil {
3759		i.mu.Lock()
3760		i.statusErr = "tree: session swap not available in this build"
3761		i.mu.Unlock()
3762		i.invalidate()
3763		return
3764	}
3765	if err := i.cfg.LoadSession(path); err != nil {
3766		i.mu.Lock()
3767		i.statusErr = "tree: load failed: " + err.Error()
3768		i.mu.Unlock()
3769		i.invalidate()
3770		return
3771	}
3772	i.mu.Lock()
3773	i.statusOK = "switched to branch " + friendlyPath(path)
3774	i.statusErr = ""
3775	i.mu.Unlock()
3776	i.invalidate()
3777}
3778
3779// applyForkSelection branches the current session at msgIdx+1 (so
3780// the selected user message and everything before it is included
3781// in the new branch), then switches the running agent to the new
3782// file. Called from the jump-dialog handler when pendingFork=true.
3783func (i *Interactive) applyForkSelection(msgIdx int) {
3784	i.pendingFork = false
3785	if i.cfg.CurrentSessionPath == nil {
3786		i.mu.Lock()
3787		i.statusErr = "fork: no session is active"
3788		i.mu.Unlock()
3789		i.invalidate()
3790		return
3791	}
3792	src := i.cfg.CurrentSessionPath()
3793	if src == "" {
3794		i.mu.Lock()
3795		i.statusErr = "fork: no session is active"
3796		i.mu.Unlock()
3797		i.invalidate()
3798		return
3799	}
3800	if i.cfg.FlushSession != nil {
3801		i.cfg.FlushSession()
3802	}
3803	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
3804	// the selected user message is included.
3805	upTo := msgIdx + 1
3806	newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
3807	if err != nil {
3808		i.mu.Lock()
3809		i.statusErr = "fork: " + err.Error()
3810		i.mu.Unlock()
3811		i.invalidate()
3812		return
3813	}
3814	if i.cfg.LoadSession == nil {
3815		i.mu.Lock()
3816		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
3817		i.statusErr = ""
3818		i.mu.Unlock()
3819		i.invalidate()
3820		return
3821	}
3822	if err := i.cfg.LoadSession(newPath); err != nil {
3823		i.mu.Lock()
3824		i.statusErr = "fork: switch failed: " + err.Error()
3825		i.mu.Unlock()
3826		i.invalidate()
3827		return
3828	}
3829	i.mu.Lock()
3830	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
3831	i.statusErr = ""
3832	i.mu.Unlock()
3833	i.invalidate()
3834}
3835
3836// formatInt is a tiny strconv.Itoa shim; keeps the handler above
3837// from needing a strconv import just for one call.
3838func formatInt(n int) string {
3839	return fmt.Sprintf("%d", n)
3840}
3841
3842// assistantText returns the concatenated text of every TextBlock in
3843// m. Used by the streaming-view dedupe guard to tell when a live
3844// streamed reply has already been promoted into the transcript.
3845func assistantText(m provider.Message) string {
3846	var sb strings.Builder
3847	for _, c := range m.Content {
3848		if tb, ok := c.(provider.TextBlock); ok {
3849			if sb.Len() > 0 {
3850				sb.WriteByte('\n')
3851			}
3852			sb.WriteString(tb.Text)
3853		}
3854	}
3855	return sb.String()
3856}
3857
3858// resetStreamingStateLocked clears every piece of streaming state
3859// in one shot. Used by abort paths (turn cancel, compact finish,
3860// queue drain) so the pacer doesn't keep draining stale runes from
3861// a prior turn. Must be called with i.mu held.
3862func (i *Interactive) resetStreamingStateLocked() {
3863	i.streaming.Reset()
3864	i.streamPending = i.streamPending[:0]
3865	i.streamFlushPending = false
3866	i.streamOn = false
3867}
3868
3869// assistantMessageSideEffects runs the non-visual hooks attached to
3870// EvAssistantMessage: the host-provided OnAssistant callback and the
3871// telegram-bridge mirror. Called with i.mu held.
3872//
3873// Factored out of handleEvent because the streaming pacer may defer
3874// visual reset until after the last buffered rune has painted, but
3875// the callbacks themselves must fire on message arrival so
3876// downstream observers (session persistence, telegram, cost panels)
3877// don't wait on a UI animation to catch up.
3878func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
3879	if i.cfg.OnAssistant != nil {
3880		i.cfg.OnAssistant(m)
3881	}
3882	if i.telegramBridge != nil && i.telegramBridge.Active() {
3883		var sb strings.Builder
3884		for _, c := range m.Content {
3885			if tb, ok := c.(provider.TextBlock); ok {
3886				if sb.Len() > 0 {
3887					sb.WriteString("\n")
3888				}
3889				sb.WriteString(tb.Text)
3890			}
3891		}
3892		if text := sb.String(); strings.TrimSpace(text) != "" {
3893			go i.telegramBridge.OnAssistantText(text)
3894		}
3895	}
3896}
3897
3898// paintPaceRate is how many runes the streaming pacer releases per
3899// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
3900// that a 500-rune summary finishes in ~1.3s, slow enough to look
3901// like a human typing. Empirically matches the feel of provider
3902// paths that already drip-stream natively.
3903const paintPaceRate = 6
3904
3905// paintPaceInterval is the tick interval for the streaming pacer.
3906// 16ms lines up with the redraw throttle so we never paint faster
3907// than the terminal can keep up.
3908const paintPaceInterval = 16 * time.Millisecond
3909
3910// runStreamPacer drains buffered deltas from streamPending into
3911// streaming a small batch per tick, invalidating after each move.
3912// It stops when the context cancels (tui shutdown).
3913//
3914// Why a pacer: providers differ wildly in how they chunk their
3915// text_delta events. The API-key path on Anthropic emits ~30 drips
3916// for a 400-token summary; the OAuth path can coalesce the same
3917// summary into 3 fat chunks, visually indistinguishable from "the
3918// whole reply just appeared". The pacer normalizes that so every
3919// path looks the same on screen.
3920func (i *Interactive) runStreamPacer(ctx context.Context) {
3921	t := time.NewTicker(paintPaceInterval)
3922	defer t.Stop()
3923	for {
3924		select {
3925		case <-ctx.Done():
3926			return
3927		case <-t.C:
3928			i.mu.Lock()
3929			if len(i.streamPending) == 0 {
3930				// EvAssistantMessage already fired but the pacer
3931				// was still draining a tick ago. Everything is now
3932				// painted; clear the streaming flags so the next
3933				// redraw shows the finalised transcript message
3934				// and hides the streaming overlay.
3935				if i.streamFlushPending {
3936					i.streamFlushPending = false
3937					i.streaming.Reset()
3938					i.streamOn = false
3939					i.mu.Unlock()
3940					i.invalidate()
3941					continue
3942				}
3943				i.mu.Unlock()
3944				continue
3945			}
3946			n := paintPaceRate
3947			if n > len(i.streamPending) {
3948				n = len(i.streamPending)
3949			}
3950			i.streaming.WriteString(string(i.streamPending[:n]))
3951			i.streamPending = i.streamPending[n:]
3952			i.mu.Unlock()
3953			i.invalidate()
3954		}
3955	}
3956}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close