v0.0.57 ef80f9c

interactive.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"time"
12
13	"github.com/patriceckhart/zot/internal/agent/extensions"
14	"github.com/patriceckhart/zot/internal/agent/modes/telegram"
15	"github.com/patriceckhart/zot/internal/agent/tools"
16	"github.com/patriceckhart/zot/internal/auth"
17	"github.com/patriceckhart/zot/internal/core"
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	// ZotHome is the root directory for sessions/, used by /sessions
57	// and the update-check cache.
58	ZotHome string
59
60	// Version is the binary's current version (from main.version).
61	// Used only for display; the update check itself is done outside
62	// this package to avoid an import cycle.
63	Version string
64
65	// UpdateInfoChan is an optional channel that delivers the result
66	// of the github-release update check. Interactive reads at most
67	// one value, drops it if the check reported nothing, and otherwise
68	// surfaces a yellow "update available" banner at the top of the
69	// chat. Nil channel = no banner, no startup cost.
70	UpdateInfoChan <-chan UpdateInfo
71
72	// Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
73	Sandbox *tools.Sandbox
74
75	// LoadSession swaps the current session for the one at path. The
76	// callback returns the new agent message slice so the TUI can invalidate.
77	LoadSession func(path string) error
78
79	// CurrentSessionPath returns the path of the live session file
80	// on disk (the one every AppendMessage writes to). Used by
81	// /session export so the exporter ships the exact bytes on
82	// disk. Returns an empty string when --no-session is set or
83	// no session is open.
84	CurrentSessionPath func() string
85
86	// PersistModel is called whenever the user switches model or provider.
87	// It should update config.json and (if there's an active session)
88	// write a new meta row so resume picks up the same model.
89	PersistModel func(providerName, model string)
90
91	OnAssistant  func(m provider.Message)
92	OnToolResult func(id string, r core.ToolResult)
93
94	// Extensions, if non-nil, lets users invoke extension-registered
95	// slash commands. Commands declared by extensions are looked up
96	// AFTER the built-in catalog so a built-in name always wins.
97	Extensions *extensions.Manager
98
99	// SkillSnapshot, if non-nil, returns the current list of
100	// discovered SKILL.md files. Re-invoked each time /skills opens
101	// so the picker reflects edits made during the session.
102	SkillSnapshot func() []*skills.Skill
103
104	// ChangelogChan, if non-nil, delivers release-notes for the
105	// current binary version once at startup. Interactive opens a
106	// dismissible overlay when the channel produces a non-empty
107	// body. Receiver fires at most once.
108	ChangelogChan <-chan ChangelogPayload
109
110	// OnChangelogDismiss, if non-nil, is called once the user
111	// closes the changelog overlay. The cli wires this to a
112	// MarkChangelogShown call so the same version doesn't show
113	// again on the next launch.
114	OnChangelogDismiss func()
115
116	// NoYolo is true when --no-yolo was passed. Interactive opens
117	// a confirmation dialog before every tool call and blocks the
118	// tool until the user picks yes / always-this-tool /
119	// always-all / no. When false (default), tools run freely.
120	NoYolo bool
121
122	// ConfirmGate is the session-scoped gate wrapping this
123	// interactive's Confirmer. When non-nil, /yolo can call
124	// AllowAll() on it to disable confirmation for the rest of the
125	// session. When nil (yolo mode), /yolo reports that there's
126	// nothing to disable.
127	ConfirmGate *core.ConfirmGate
128}
129
130// ChangelogPayload mirrors agent.ChangelogInfo without the import
131// cycle. The cli builds one from the http response, the tui opens
132// the overlay when one arrives.
133type ChangelogPayload struct {
134	Version string
135	Body    string
136	URL     string
137}
138
139// Interactive is the TUI chat loop.
140type Interactive struct {
141	cfg  InteractiveConfig
142	view *tui.View
143	ed   *tui.Editor
144	rend *tui.Renderer
145
146	mu           sync.Mutex
147	agent        *core.Agent
148	streaming    strings.Builder
149	streamOn     bool
150	toolCalls    map[string]*tui.ToolCallView
151	toolOrder    []string
152	statusErr    string
153	statusOK     string
154	helpBlock    []string // rendered above the chat when /help was typed
155	cumUsage     provider.Usage
156	lastCtxInput int // input_tokens of the most recent turn — approximates current context size
157	busy         bool
158	dirty        chan struct{}
159	cancelTurn   context.CancelFunc
160	scrollOffset int // rows from the bottom; 0 = pinned to latest
161
162	// Messages typed while a turn is in flight. Each is delivered as
163	// its own follow-up turn once the current one finishes. Rendered
164	// above the status bar as "sliding in: ..." chips.
165	queued []string
166
167	// runCtx is the top-level context passed to Run(). Follow-up turns
168	// drained from `queued` are started against this context so they
169	// survive past the ctx of the key event that enqueued them.
170	runCtx context.Context
171
172	// autoCompacting is true while a model-triggered compaction is in
173	// flight. Surfaced in the status bar so the user can tell a
174	// condense pass from a regular assistant turn.
175	autoCompacting bool
176
177	// updateInfo is the result of the async update check. Zero value
178	// while the check hasn't completed or nothing is available.
179	updateInfo UpdateInfo
180
181	dialog           *loginDialog
182	modelDialog      *modelDialog
183	sessionDialog    *sessionDialog
184	jumpDialog       *jumpDialog
185	btwDialog        *btwDialog
186	skillsDialog     *skillsDialog
187	changelogDialog  *changelogDialog
188	confirmDialog    *confirmDialog
189	logoutDialog     *logoutDialog
190	telegramDialog   *telegramDialog
191	telegramBridge   *telegram.Bridge
192	sessionOpsDialog *sessionOpsDialog
193	suggest          *slashSuggester
194	spin             *spinner
195
196	// parkedTurn is the 1-based turn number the viewport is currently
197	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
198	// Rendered as a muted footer at the bottom of the chat so users
199	// don't forget they're looking at history.
200	parkedTurn  int
201	parkedTotal int
202
203	// lastCtrlC is when the user last pressed ctrl+c. The first press
204	// clears the editor / cancels a turn / shows a hint; a second press
205	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
206	lastCtrlC time.Time
207
208	// welcomeStart is when the interactive run began. The welcome
209	// banner shows the binary version for welcomeVersionDuration
210	// after this point and reverts to plain text after.
211	welcomeStart time.Time
212
213	// extNotes are one-shot styled lines pushed by extensions via
214	// Notify / Display. They live above the editor (just below the
215	// transcript) until cleared by /clear or another reset.
216	extNotes []string
217}
218
219// welcomeVersionDuration is how long the welcome banner shows the
220// version suffix before reverting to the plain headline. 1.5s is
221// enough to read at a glance and keeps the splash short.
222const welcomeVersionDuration = 1500 * time.Millisecond
223
224// NewInteractive constructs an Interactive from cfg.
225func NewInteractive(cfg InteractiveConfig) *Interactive {
226	i := &Interactive{
227		cfg: cfg,
228		view: &tui.View{
229			Theme:      cfg.Theme,
230			ImageProto: tui.DetectImageProtocol(),
231		},
232		ed:               tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
233		rend:             tui.NewRenderer(cfg.Terminal),
234		toolCalls:        map[string]*tui.ToolCallView{},
235		dirty:            make(chan struct{}, 8),
236		dialog:           newLoginDialog(),
237		modelDialog:      newModelDialog(),
238		sessionDialog:    newSessionDialog(),
239		jumpDialog:       newJumpDialog(),
240		btwDialog:        newBtwDialog(),
241		skillsDialog:     newSkillsDialog(),
242		changelogDialog:  newChangelogDialog(),
243		confirmDialog:    newConfirmDialog(),
244		logoutDialog:     newLogoutDialog(),
245		telegramDialog:   newTelegramDialog(),
246		sessionOpsDialog: newSessionOpsDialog(),
247		suggest:          newSlashSuggester(),
248		spin:             newSpinner(),
249	}
250	if cfg.Agent != nil {
251		i.agent = cfg.Agent
252		i.view.Messages = cfg.Agent.Messages()
253	}
254	return i
255}
256
257// Run blocks until the user quits.
258func (i *Interactive) Run(ctx context.Context) error {
259	i.runCtx = ctx
260	term := i.cfg.Terminal
261	restore, err := term.EnterRaw()
262	if err != nil {
263		return err
264	}
265	defer restore()
266	defer func() {
267		if i.telegramBridge != nil {
268			i.telegramBridge.Stop()
269		}
270	}()
271
272	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
273	_, _ = term.Write([]byte(tui.SeqAltScreenOn))
274	defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
275
276	cols, rows := term.Size()
277	i.rend.Resize(cols, rows)
278	term.OnResize(func() {
279		c, r := term.Size()
280		i.rend.Resize(c, r)
281		// Force an immediate redraw on resize. The throttled invalidate
282		// path is fine for animation, but a window resize is a discrete
283		// user action where any visible delay (or stale frame) reads as
284		// brokenness. redraw() is mutex-safe; the worst that happens is
285		// a duplicate paint if the throttler is mid-flight, which is
286		// invisible.
287		i.redraw()
288	})
289
290	if i.cfg.InitialInput != "" {
291		i.ed.SetValue(i.cfg.InitialInput)
292	}
293
294	// Stamp the welcome time and schedule a one-shot redraw at the
295	// expiry so the version suffix disappears on its own even if the
296	// user hasn't typed anything yet.
297	i.welcomeStart = time.Now()
298	time.AfterFunc(welcomeVersionDuration, i.invalidate)
299
300	// If the agent was constructed with a pre-loaded transcript
301	// (--continue, --resume, --session) park the viewport on the
302	// most recent turn so the user lands looking at where the
303	// previous session left off rather than at the bottom of an
304	// already-rendered final reply.
305	if i.agent != nil {
306		if msgs := i.agent.Messages(); len(msgs) > 0 {
307			i.scrollToLastTurn(msgs)
308		}
309	}
310
311	// No credential at startup? Auto-open the login dialog, and mark
312	// the status line. The user can Esc out of the dialog if they
313	// want to dismiss it (e.g. to check /help or /exit first).
314	if i.agent == nil {
315		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
316		i.dialog.Open()
317	}
318
319	// Input goroutine.
320	keys := make(chan tui.Key, 8)
321	go func() {
322		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
323		for {
324			k, err := reader.Read()
325			if err != nil {
326				return
327			}
328			keys <- k
329		}
330	}()
331
332	// Subscribe to auth events.
333	var authEvents <-chan auth.Event
334	if i.cfg.AuthManager != nil {
335		authEvents = i.cfg.AuthManager.Events()
336	}
337
338	// Animation ticker: drives spinner and dialog-related redraws when
339	// nothing else changed. 120ms is slow enough that highlighting a huge
340	// transcript doesn't spin the cpu.
341	tick := time.NewTicker(120 * time.Millisecond)
342	defer tick.Stop()
343
344	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
345	// at most once every redrawMinInterval. Huge tool-result dumps can
346	// fire hundreds of invalidations while the user is typing; without
347	// this, the input goroutine never gets CPU and keystrokes lag.
348	const redrawMinInterval = 16 * time.Millisecond
349	var lastRedraw time.Time
350	var pendingRedraw bool
351	var pendingTimer *time.Timer
352
353	drainPending := func() {
354		if pendingTimer != nil {
355			pendingTimer.Stop()
356			pendingTimer = nil
357		}
358		if pendingRedraw {
359			pendingRedraw = false
360			lastRedraw = time.Now()
361			i.redraw()
362		}
363	}
364
365	requestRedraw := func() {
366		since := time.Since(lastRedraw)
367		if since >= redrawMinInterval {
368			// Redrawing right now subsumes any pending redraw, so clear
369			// the throttle state. Without this, a pending flag stays
370			// stuck at true and subsequent invalidate() calls within
371			// redrawMinInterval get dropped — which is exactly how the
372			// final "turn finished" frame went missing until the user
373			// nudged the ui by typing or scrolling.
374			if pendingTimer != nil {
375				pendingTimer.Stop()
376			}
377			pendingRedraw = false
378			lastRedraw = time.Now()
379			i.redraw()
380			return
381		}
382		if pendingRedraw {
383			return // already scheduled
384		}
385		pendingRedraw = true
386		wait := redrawMinInterval - since
387		if pendingTimer == nil {
388			pendingTimer = time.AfterFunc(wait, func() {
389				// Poke the dirty channel so the main loop wakes and
390				// drains the pending redraw on its own goroutine. We
391				// can't call drainPending here directly — it touches
392				// closure state shared with the main loop.
393				i.invalidate()
394			})
395		} else {
396			pendingTimer.Reset(wait)
397		}
398	}
399
400	i.invalidate()
401
402	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
403	changelog := i.cfg.ChangelogChan // single-shot, see case below
404
405	for {
406		select {
407		case <-ctx.Done():
408			return ctx.Err()
409		case k := <-keys:
410			if done := i.handleKey(ctx, k); done {
411				return nil
412			}
413			i.invalidate()
414		case ev := <-authEvents:
415			i.handleAuthEvent(ev)
416			i.invalidate()
417		case info, ok := <-updates:
418			if ok && info.Available {
419				i.mu.Lock()
420				i.updateInfo = info
421				i.mu.Unlock()
422				i.invalidate()
423			}
424			updates = nil // single-shot; subsequent iterations skip this case
425		case cl, ok := <-changelog:
426			if ok && cl.Body != "" {
427				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
428				i.invalidate()
429			}
430			changelog = nil // single-shot
431		case <-i.dirty:
432			requestRedraw()
433		case <-tick.C:
434			// Always drain a pending redraw on the tick. This is the
435			// safety net that catches the case where the dirty channel
436			// was saturated when the final "turn finished" invalidate
437			// fired, or where the throttle scheduled a deferred redraw
438			// and the AfterFunc-driven invalidate got dropped on a
439			// full channel.
440			drainPending()
441			if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() {
442				requestRedraw() // keep the spinner / dialog animation moving
443			}
444		}
445	}
446}
447
448func (i *Interactive) invalidate() {
449	select {
450	case i.dirty <- struct{}{}:
451	default:
452	}
453}
454
455// lastCols returns the current terminal width in columns.
456func (i *Interactive) lastCols() int {
457	cols, _ := i.cfg.Terminal.Size()
458	return cols
459}
460
461// chatPage returns the number of chat rows currently visible, used
462// as the page size for PageUp/PageDown.
463func (i *Interactive) chatPage() int {
464	_, rows := i.cfg.Terminal.Size()
465	p := rows - 6 // rough reservation for status + editor + a dialog line
466	if p < 4 {
467		p = 4
468	}
469	return p
470}
471
472// scrollBy adjusts the scroll offset. Positive = up (into history).
473// Clearing the parked-turn label when we're back at the bottom means
474// the "viewing turn N" footer goes away automatically as soon as you
475// scroll back to the live tail.
476func (i *Interactive) scrollBy(delta int) {
477	i.mu.Lock()
478	i.scrollOffset += delta
479	if i.scrollOffset < 0 {
480		i.scrollOffset = 0
481	}
482	if i.scrollOffset == 0 {
483		i.parkedTurn = 0
484		i.parkedTotal = 0
485	}
486	i.mu.Unlock()
487	i.invalidate()
488}
489
490// scrollToBottom pins the view to the latest content.
491func (i *Interactive) scrollToBottom() {
492	i.mu.Lock()
493	i.scrollOffset = 0
494	i.parkedTurn = 0
495	i.parkedTotal = 0
496	i.mu.Unlock()
497	i.invalidate()
498}
499
500func (i *Interactive) redraw() {
501	i.mu.Lock()
502	defer i.mu.Unlock()
503
504	cols, _ := i.cfg.Terminal.Size()
505	if i.agent != nil {
506		i.view.Messages = i.agent.Messages()
507	} else {
508		i.view.Messages = nil
509	}
510	i.view.Streaming = i.streaming.String()
511	i.view.StreamingActive = i.streamOn
512	// Belt and suspenders: if the transcript's last message is already an
513	// assistant message, the streaming view is stale (EvAssistantMessage may
514	// not have fired yet) — suppress it to avoid double-rendering the reply.
515	if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
516		i.view.StreamingActive = false
517	}
518	// Live tool-call view: only shown while a turn is in flight. Once
519	// the agent is idle, every tool call has already been folded into
520	// the transcript (as assistant.ToolCallBlock + a tool-role message),
521	// so showing v.ToolCalls a second time would duplicate them below
522	// the final assistant text — which looks like the summary came
523	// "before" the tools.
524	i.view.ToolCalls = i.view.ToolCalls[:0]
525	if i.busy {
526		for _, id := range i.toolOrder {
527			if tc, ok := i.toolCalls[id]; ok {
528				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
529			}
530		}
531	}
532	i.view.Err = i.statusErr
533
534	chat := i.view.Build(cols)
535
536	// Welcome banner: shown at the top of the chat area when there is
537	// no transcript yet. Disappears after the first message is sent.
538	// The version suffix is shown for welcomeVersionDuration after
539	// startup, then drops off automatically.
540	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
541		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
542		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
543	}
544
545	// Update-available banner: prepended above everything else so it's
546	// the first thing the user sees when opening a new zot session.
547	// Once rendered, it stays until the user updates to a newer
548	// version — we don't persist a "dismissed" flag because this is
549	// cheap and re-showing it is how most users remember to update.
550	if i.updateInfo.Available {
551		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
552		chat = append(banner, chat...)
553	}
554
555	// /help block: appended to the transcript so it appears at the
556	// bottom of the chat area (right above the status bar / editor).
557	// Prepending it would push long conversations off the top of the
558	// viewport, which users would miss entirely.
559	if len(i.helpBlock) > 0 {
560		chat = append(chat, i.helpBlock...)
561	}
562
563	if i.statusOK != "" {
564		// Hard-truncate the OK line to the visible width so a long
565		// session path ("resumed session: /Users/.../sessions/...")
566		// doesn't overflow past the right edge and look broken on a
567		// narrow terminal.
568		line := "✓ " + i.statusOK
569		if cols > 4 && len(line) > cols {
570			line = line[:cols-1] + "…"
571		}
572		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
573	}
574
575	// Extension notes (notify / display) live just under the
576	// transcript, above the dialog/editor band. Cleared by /clear.
577	if len(i.extNotes) > 0 {
578		chat = append(chat, i.extNotes...)
579		chat = append(chat, "")
580	}
581
582	// Dialogs (login or model picker) render between chat and the editor.
583	var dialog []string
584	switch {
585	case i.dialog.Active():
586		dialog = i.dialog.Render(i.cfg.Theme, cols)
587	case i.modelDialog.Active():
588		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
589	case i.sessionDialog.Active():
590		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
591	case i.jumpDialog.Active():
592		dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
593	case i.btwDialog.Active():
594		dialog = i.btwDialog.Render(i.cfg.Theme, cols)
595	case i.skillsDialog.Active():
596		dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
597	case i.changelogDialog.Active():
598		dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
599	case i.confirmDialog.Active():
600		dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
601	case i.logoutDialog.Active():
602		dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
603	case i.telegramDialog.Active():
604		dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
605	case i.sessionOpsDialog.Active():
606		dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
607	}
608
609	// Slash-command autocomplete: popup above the status line, only
610	// when the editor starts with "/" and no dialog is already open.
611	// Feed extension-registered commands into the suggester first so
612	// they show up in tab-complete + the popup alongside the built-ins.
613	if i.cfg.Extensions != nil {
614		catalog := i.cfg.Extensions.Commands()
615		extra := make([]slashCommand, 0, len(catalog))
616		for _, c := range catalog {
617			// The popup renders extension commands under a dedicated
618			// "── extensions ───" divider, so the description doesn't
619			// need to repeat the source. If the description is empty,
620			// fall back to the extension name so the row isn't blank.
621			desc := c.Description
622			if strings.TrimSpace(desc) == "" {
623				desc = "(" + c.Extension + ")"
624			}
625			extra = append(extra, slashCommand{
626				Name: "/" + c.Name,
627				Desc: desc,
628			})
629		}
630		i.suggest.SetExtra(extra)
631	}
632	var suggest []string
633	currentInput := i.ed.Value()
634	if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
635		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
636	}
637
638	// Busy prefix shown at the far left of the status bar. The
639	// spinner glyph and its funny-line message share the `zot`
640	// label colour (Theme.Assistant) so the whole "who's working"
641	// band reads at a glance. Elapsed time stays muted because it
642	// drifts every second and shouldn't grab focus.
643	var busyPrefix string
644	if i.busy {
645		busyPrefix = fmt.Sprintf("%s %s · %s",
646			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
647			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
648			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
649		)
650	}
651
652	ctxMax := 0
653	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
654		ctxMax = m.ContextWindow
655	}
656	statusLines := tui.StatusBar(tui.StatusBarParams{
657		Theme:          i.cfg.Theme,
658		Provider:       i.cfg.Provider,
659		Model:          i.cfg.Model,
660		Busy:           i.busy,
661		BusyPrefix:     busyPrefix,
662		CWD:            i.cfg.CWD,
663		Locked:         i.cfg.Sandbox.Locked(),
664		Usage:          i.cumUsage,
665		Subscription:   i.cfg.AuthMethod == "oauth",
666		ContextUsed:    i.lastCtxInput,
667		ContextMax:     ctxMax,
668		AutoCompacting: i.autoCompacting,
669		Telegram:       i.telegramBridge != nil && i.telegramBridge.Active(),
670		Cols:           cols,
671	})
672	edLines, curR, curC := i.ed.Render(cols)
673
674	// "Sliding in" chips for messages the user typed while a turn is
675	// in flight. Shown directly above the status bar so they're close
676	// to the editor but don't push the chat around.
677	var queue []string
678	if len(i.queued) > 0 {
679		for _, q := range i.queued {
680			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
681			text := truncateLine(q, cols-15)
682			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
683		}
684	}
685
686	// Bottom-sticky sections (always visible, never scroll).
687	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
688	bottom = append(bottom, dialog...)
689	bottom = append(bottom, suggest...)
690	bottom = append(bottom, queue...)
691	bottom = append(bottom, statusLines...)
692	bottom = append(bottom, edLines...)
693
694	_, rows := i.cfg.Terminal.Size()
695	chatRows := rows - len(bottom)
696	if chatRows < 1 {
697		chatRows = 1
698	}
699
700	// Apply scroll offset to the chat slice.
701	maxOffset := len(chat) - chatRows
702	if maxOffset < 0 {
703		maxOffset = 0
704	}
705	if i.scrollOffset > maxOffset {
706		i.scrollOffset = maxOffset
707	}
708	if i.scrollOffset < 0 {
709		i.scrollOffset = 0
710	}
711
712	var visibleChat []string
713	if len(chat) <= chatRows {
714		visibleChat = chat
715	} else {
716		end := len(chat) - i.scrollOffset
717		start := end - chatRows
718		if start < 0 {
719			start = 0
720		}
721		visibleChat = chat[start:end]
722	}
723
724	// A tiny "scrolled up" indicator in the top-right of the chat pane
725	// so you know you're not at the bottom. When the viewport was
726	// parked by /jump we include the turn number so the user remembers
727	// they're reading history rather than the live conversation.
728	if i.scrollOffset > 0 && len(visibleChat) > 0 {
729		var text string
730		if i.parkedTurn > 0 && i.parkedTotal > 0 {
731			text = fmt.Sprintf("  ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
732				i.parkedTurn, i.parkedTotal, i.scrollOffset)
733		} else {
734			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
735		}
736		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
737		visibleChat = append([]string{note}, visibleChat...)
738		if len(visibleChat) > chatRows {
739			visibleChat = visibleChat[:chatRows]
740		}
741	}
742
743	frame := make([]string, 0, len(visibleChat)+len(bottom))
744	frame = append(frame, visibleChat...)
745	frame = append(frame, bottom...)
746
747	cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
748	cursorCol := curC
749	i.rend.Draw(frame, cursorRow, cursorCol)
750}
751
752// truncateLine shortens s so it fits within n display cells, with an
753// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
754// novel doesn't blow past the status line.
755func truncateLine(s string, n int) string {
756	if n <= 0 {
757		return ""
758	}
759	// Collapse newlines — chips are single line.
760	s = strings.ReplaceAll(s, "\n", " ↩ ")
761	runes := []rune(s)
762	if len(runes) <= n {
763		return s
764	}
765	if n <= 1 {
766		return "…"
767	}
768	return string(runes[:n-1]) + "…"
769}
770
771// ctrlCExitWindow is how long after a ctrl+c press a *second* press
772// will exit instead of just clearing input. Long enough to be
773// deliberate (rules out accidental key chord), short enough that the
774// hint stays meaningful.
775const ctrlCExitWindow = 2 * time.Second
776
777// armCtrlCExit records the timestamp of the current ctrl+c so the next
778// one within ctrlCExitWindow exits.
779func (i *Interactive) armCtrlCExit() {
780	i.mu.Lock()
781	i.lastCtrlC = time.Now()
782	i.mu.Unlock()
783}
784
785// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
786// that another press should now exit.
787func (i *Interactive) ctrlCExitArmed() bool {
788	i.mu.Lock()
789	t := i.lastCtrlC
790	i.mu.Unlock()
791	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
792}
793
794func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
795	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
796	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
797	// unexpectedly. The hint message also goes stale; clear it.
798	if k.Kind != tui.KeyCtrlC {
799		i.mu.Lock()
800		if !i.lastCtrlC.IsZero() {
801			i.lastCtrlC = time.Time{}
802			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
803				i.statusOK = ""
804			}
805		}
806		i.mu.Unlock()
807	}
808
809	// Dialogs consume keys while open (except ctrl+c, which always closes them).
810
811	// Confirm dialog has highest priority: the agent goroutine is
812	// blocked waiting for an answer, so we must not let keys leak
813	// anywhere else while it's up.
814	if i.confirmDialog.Active() {
815		i.confirmDialog.HandleKey(k)
816		i.invalidate()
817		return false
818	}
819	if i.dialog.Active() {
820		if k.Kind == tui.KeyCtrlC {
821			i.dialog.Close()
822			if i.cfg.AuthManager != nil {
823				i.cfg.AuthManager.CancelOAuth()
824			}
825			return false
826		}
827		act := i.dialog.HandleKey(k)
828		if act.StartAPIKey {
829			i.startAPIKeyFlow(act.Provider)
830		}
831		if act.StartOAuth {
832			i.startOAuthFlow(act.Provider)
833		}
834		return false
835	}
836	if i.modelDialog.Active() {
837		if k.Kind == tui.KeyCtrlC {
838			i.modelDialog.Close()
839			return false
840		}
841		act := i.modelDialog.HandleKey(k)
842		if act.Select {
843			i.applyModelSelection(act.Provider, act.Model)
844		}
845		return false
846	}
847	if i.sessionDialog.Active() {
848		if k.Kind == tui.KeyCtrlC {
849			i.sessionDialog.Close()
850			return false
851		}
852		act := i.sessionDialog.HandleKey(k)
853		if act.Select {
854			i.applySessionSelection(act.Path)
855		}
856		return false
857	}
858	if i.logoutDialog.Active() {
859		if k.Kind == tui.KeyCtrlC {
860			i.logoutDialog.Close()
861			i.invalidate()
862			return false
863		}
864		act := i.logoutDialog.HandleKey(k)
865		if act.Select {
866			i.doLogout(act.Target)
867		}
868		i.invalidate()
869		return false
870	}
871	if i.telegramDialog.Active() {
872		if k.Kind == tui.KeyCtrlC {
873			i.telegramDialog.Close()
874			i.invalidate()
875			return false
876		}
877		act := i.telegramDialog.HandleKey(k)
878		if act.Select {
879			i.doTelegram(act.Action)
880		}
881		i.invalidate()
882		return false
883	}
884	if i.sessionOpsDialog.Active() {
885		if k.Kind == tui.KeyCtrlC {
886			i.sessionOpsDialog.Close()
887			i.invalidate()
888			return false
889		}
890		act := i.sessionOpsDialog.HandleKey(k)
891		if act.Select {
892			i.doSessionOp(act.Action, "")
893		}
894		i.invalidate()
895		return false
896	}
897	if i.jumpDialog.Active() {
898		if k.Kind == tui.KeyCtrlC {
899			i.jumpDialog.Close()
900			return false
901		}
902		act := i.jumpDialog.HandleKey(k)
903		if act.Select {
904			i.applyJumpSelection(act.MessageIdx, act.TurnNo)
905		}
906		return false
907	}
908	if i.btwDialog.Active() {
909		if k.Kind == tui.KeyCtrlC {
910			i.btwDialog.Close()
911			i.invalidate()
912			return false
913		}
914		i.btwDialog.HandleKey(k, i.invalidate)
915		return false
916	}
917	if i.skillsDialog.Active() {
918		if k.Kind == tui.KeyCtrlC {
919			i.skillsDialog.Close()
920			i.invalidate()
921			return false
922		}
923		i.skillsDialog.HandleKey(k)
924		i.invalidate()
925		return false
926	}
927	if i.changelogDialog.Active() {
928		if closed := i.changelogDialog.HandleKey(k); closed {
929			// User dismissed; let the parent persist the
930			// LastChangelogShown marker via the close callback.
931			if i.cfg.OnChangelogDismiss != nil {
932				i.cfg.OnChangelogDismiss()
933			}
934		}
935		i.invalidate()
936		return false
937	}
938
939	// Global keys.
940	switch k.Kind {
941	case tui.KeyCtrlC:
942		// While busy: cancel the active turn (same as esc). The exit
943		// hint stays armed so a quick second ctrl+c after the turn
944		// dies still exits, matching habits from other repls.
945		if i.busy && i.cancelTurn != nil {
946			i.cancelTurn()
947			i.armCtrlCExit()
948			return false
949		}
950		// Idle: first press clears the editor (and any queued
951		// follow-up messages); a second press within ctrlCExitWindow
952		// exits. With both an empty editor and no queue the first
953		// press still just arms — require a deliberate double-tap.
954		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
955		if hadInput {
956			i.ed.Clear()
957			i.suggest.Reset()
958			i.mu.Lock()
959			i.queued = nil
960			i.statusOK = "input cleared"
961			i.statusErr = ""
962			i.mu.Unlock()
963			i.armCtrlCExit()
964			return false
965		}
966		if i.ctrlCExitArmed() {
967			return true
968		}
969		i.mu.Lock()
970		i.statusOK = "press ctrl+c again to exit"
971		i.statusErr = ""
972		i.mu.Unlock()
973		i.armCtrlCExit()
974		return false
975	case tui.KeyEsc:
976		// Esc interrupts a running turn. When idle, fall through so the
977		// editor can clear itself.
978		if i.busy && i.cancelTurn != nil {
979			i.cancelTurn()
980			// If a confirm dialog is pending, refuse it so the agent
981			// goroutine unblocks and the context cancellation can
982			// actually take effect.
983			i.confirmDialog.CancelAll("turn cancelled")
984			return false
985		}
986	case tui.KeyCtrlD:
987		if i.ed.IsEmpty() && !i.busy {
988			return true
989		}
990	case tui.KeyCtrlL:
991		i.rend.Clear()
992		i.invalidate()
993		return false
994	case tui.KeyCtrlO:
995		// Toggle expansion of collapsed tool results. Affects every tool
996		// call in the transcript — press again to re-collapse.
997		i.mu.Lock()
998		i.view.ExpandAll = !i.view.ExpandAll
999		i.mu.Unlock()
1000		i.invalidate()
1001		return false
1002	case tui.KeyPageUp:
1003		i.scrollBy(+i.chatPage())
1004		return false
1005	case tui.KeyPageDown:
1006		i.scrollBy(-i.chatPage())
1007		return false
1008	case tui.KeyUp:
1009		// Wheel-up in alt screen sends Up arrows on most terminals.
1010		// When the editor is empty we use up/down for chat scroll —
1011		// independently of whether the agent is busy, so users can
1012		// scroll back through long streaming replies while they run.
1013		if i.ed.IsEmpty() {
1014			i.scrollBy(+3)
1015			return false
1016		}
1017	case tui.KeyDown:
1018		if i.ed.IsEmpty() {
1019			if i.scrollOffset > 0 {
1020				i.scrollBy(-3)
1021			}
1022			return false
1023		}
1024	}
1025
1026	// Note: we intentionally do NOT gate the editor on i.busy here.
1027	// Typing while the agent is working is supported — submitted
1028	// messages are queued and delivered as follow-up turns when the
1029	// current turn ends. See the submit handler below.
1030
1031	if k.Kind == tui.KeyEnter && k.Alt {
1032		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1033		return false
1034	}
1035
1036	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1037	if i.suggest.Active(i.ed.Value()) {
1038		switch k.Kind {
1039		case tui.KeyUp:
1040			i.suggest.Up()
1041			return false
1042		case tui.KeyDown:
1043			i.suggest.Down()
1044			return false
1045		case tui.KeyTab:
1046			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1047				i.ed.SetValue(name)
1048				i.suggest.Reset()
1049			}
1050			return false
1051		case tui.KeyEnter:
1052			// Enter on an ambiguous or partial slash prefix: complete to the
1053			// currently highlighted command and run it. That way typing
1054			// "/lo" + enter picks whichever of /login or /logout is selected
1055			// in the popup instead of submitting "/lo" as unknown. Also
1056			// clear the editor so the command doesn't linger after the
1057			// dialog opens/closes.
1058			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1059				i.ed.PushHistory(name)
1060				i.ed.Clear()
1061				i.suggest.Reset()
1062				return i.runSlash(ctx, name)
1063			}
1064		case tui.KeyEsc:
1065			i.ed.Clear()
1066			i.suggest.Reset()
1067			return false
1068		}
1069	}
1070
1071	if submit := i.ed.HandleKey(k); submit {
1072		text := strings.TrimRight(i.ed.Value(), "\n")
1073		if text == "" {
1074			return false
1075		}
1076		i.ed.PushHistory(text)
1077		i.ed.Clear()
1078		i.suggest.Reset()
1079
1080		if looksLikeSlashCommand(text) {
1081			head := text
1082			rest := ""
1083			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1084				head = text[:idx]
1085				rest = strings.TrimSpace(text[idx:])
1086			}
1087			if !isKnownSlashCommand(text) {
1088				// Try extensions before giving up. Extensions register
1089				// commands by bare name (no leading slash); strip it here.
1090				extName := strings.TrimPrefix(head, "/")
1091				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1092					go i.invokeExtensionCommand(ctx, extName, rest)
1093					return false
1094				}
1095				i.mu.Lock()
1096				i.statusErr = "unknown command " + head + " — type /help to see the list"
1097				i.statusOK = ""
1098				i.mu.Unlock()
1099				return false
1100			}
1101			// Slash commands run regardless of busy state. Commands that
1102			// would mutate the transcript or replace the agent (/clear,
1103			// /compact, /logout, /login, /model) cancel the active turn
1104			// first and wait for the goroutine to wind down so they don't
1105			// race with a streaming response. Safe commands (/help,
1106			// /jump, /sessions, /jail, /unjail, /exit) run immediately
1107			// without disturbing the active turn.
1108			if slashCancelsTurn(head) {
1109				i.cancelAndWaitForIdle()
1110			}
1111			return i.runSlash(ctx, text)
1112		}
1113
1114		if i.agent == nil {
1115			i.mu.Lock()
1116			i.statusErr = "not logged in. type /login first."
1117			i.mu.Unlock()
1118			return false
1119		}
1120		// Mirror the user's typed prompt into the paired Telegram
1121		// chat (when the bridge is active) so the Telegram thread
1122		// stays a complete record of the session, not just the half
1123		// that originated on the phone. On a goroutine so the
1124		// network write doesn't delay the local turn.
1125		if i.telegramBridge != nil && i.telegramBridge.Active() {
1126			go i.telegramBridge.OnUserTyped(text)
1127		}
1128		// If a turn is already in flight, queue this prompt instead of
1129		// starting a second one. The drain loop at the end of startTurn
1130		// will pick it up when the current turn finishes.
1131		i.mu.Lock()
1132		if i.busy {
1133			i.queued = append(i.queued, text)
1134			i.mu.Unlock()
1135			i.invalidate()
1136			return false
1137		}
1138		i.mu.Unlock()
1139		i.startTurn(ctx, text)
1140	}
1141	return false
1142}
1143
1144// invokeExtensionCommand fires an extension-registered slash command
1145// in a background goroutine, awaits the response, and applies the
1146// requested action (prompt / insert / display / noop). Errors and
1147// timeouts surface as a status_err line.
1148func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1149	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1150	if err != nil {
1151		i.mu.Lock()
1152		i.statusErr = "extension /" + name + ": " + err.Error()
1153		i.statusOK = ""
1154		i.mu.Unlock()
1155		i.invalidate()
1156		return
1157	}
1158	if resp.Error != "" {
1159		i.mu.Lock()
1160		i.statusErr = "extension /" + name + ": " + resp.Error
1161		i.statusOK = ""
1162		i.mu.Unlock()
1163		i.invalidate()
1164		return
1165	}
1166	switch resp.Action {
1167	case "prompt":
1168		if strings.TrimSpace(resp.Prompt) == "" {
1169			return
1170		}
1171		i.startTurn(i.runCtx, resp.Prompt)
1172	case "insert":
1173		i.ed.Insert(resp.Insert)
1174		i.invalidate()
1175	case "display":
1176		i.appendExtensionNote(name, resp.Display, "info")
1177	case "noop", "":
1178		// nothing
1179	default:
1180		i.mu.Lock()
1181		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1182		i.mu.Unlock()
1183		i.invalidate()
1184	}
1185}
1186
1187// appendExtensionNote renders an extension-originated note in the
1188// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1189// "success" (tool/ok green).
1190func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1191	if msg == "" {
1192		return
1193	}
1194	i.mu.Lock()
1195	defer i.mu.Unlock()
1196	color := i.cfg.Theme.Muted
1197	switch level {
1198	case "warn":
1199		color = i.cfg.Theme.Warning
1200	case "error":
1201		color = i.cfg.Theme.Error
1202	case "success":
1203		color = i.cfg.Theme.Tool
1204	}
1205	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1206	for _, line := range strings.Split(msg, "\n") {
1207		i.statusOK = "" // clear any stale ok
1208		i.statusErr = ""
1209		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1210	}
1211}
1212
1213// HostHooks implementation for the extension manager. The manager
1214// holds an interface, not a concrete *Interactive, so these methods
1215// are the only thing the manager sees.
1216
1217// Notify is the manager's NotifyFromExt entry point.
1218func (i *Interactive) Notify(extName, level, message string) {
1219	i.appendExtensionNote(extName, message, level)
1220	i.invalidate()
1221}
1222
1223// Submit feeds text through the agent loop as if the user had typed it.
1224func (i *Interactive) Submit(text string) {
1225	i.startTurn(i.runCtx, text)
1226}
1227
1228// SubmitOrQueue runs text immediately if the agent is idle, or
1229// appends it to the pending queue if a turn is already in flight.
1230// Used by the telegram bridge (and by the editor submit path) so
1231// both input sources share the same "queue behind an active turn"
1232// semantics. Images are ignored for now — only the text prompt is
1233// forwarded — because the queued-prompt path is text-only; a
1234// follow-up can expand the queue entry to carry images.
1235func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1236	_ = images // reserved for future queued-with-images support
1237	i.mu.Lock()
1238	if i.agent == nil {
1239		i.statusErr = "not logged in. type /login first."
1240		i.mu.Unlock()
1241		i.invalidate()
1242		return
1243	}
1244	if i.busy {
1245		i.queued = append(i.queued, text)
1246		i.mu.Unlock()
1247		i.invalidate()
1248		return
1249	}
1250	i.mu.Unlock()
1251	i.startTurn(i.runCtx, text)
1252}
1253
1254// CancelTurn aborts the active turn if one is running. Used by the
1255// telegram bridge when the paired user sends /stop.
1256func (i *Interactive) CancelTurn() {
1257	i.mu.Lock()
1258	cancel := i.cancelTurn
1259	i.mu.Unlock()
1260	if cancel != nil {
1261		cancel()
1262	}
1263}
1264
1265// Insert places text at the cursor in the editor.
1266func (i *Interactive) Insert(text string) {
1267	i.ed.Insert(text)
1268	i.invalidate()
1269}
1270
1271// Display appends a styled note from extName to the chat without a
1272// model call.
1273func (i *Interactive) Display(extName, text string) {
1274	i.appendExtensionNote(extName, text, "info")
1275	i.invalidate()
1276}
1277
1278func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1279	parts := strings.Fields(cmd)
1280	switch parts[0] {
1281	case "/exit":
1282		return true
1283	case "/clear":
1284		if i.agent != nil {
1285			i.agent.SetMessages(nil)
1286		}
1287		i.mu.Lock()
1288		i.toolCalls = map[string]*tui.ToolCallView{}
1289		i.toolOrder = nil
1290		i.statusErr = ""
1291		i.statusOK = ""
1292		i.helpBlock = nil
1293		i.parkedTurn = 0
1294		i.parkedTotal = 0
1295		i.scrollOffset = 0
1296		i.extNotes = nil
1297		i.view.InvalidateRenderCache()
1298		i.mu.Unlock()
1299	case "/help":
1300		i.mu.Lock()
1301		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1302		i.statusErr = ""
1303		i.statusOK = ""
1304		// Pin the viewport to the newest content so the help block,
1305		// which we just appended to the end of the transcript, is
1306		// what the user actually sees.
1307		i.scrollOffset = 0
1308		i.mu.Unlock()
1309	case "/login":
1310		i.dialog.Open()
1311	case "/logout":
1312		if len(parts) >= 2 {
1313			// Explicit target: /logout anthropic | openai | all
1314			i.doLogout(parts[1])
1315			break
1316		}
1317		// No arg: open the picker over whichever providers are
1318		// currently logged in. If nothing's logged in, bail with a
1319		// status line.
1320		i.openLogoutDialog()
1321	case "/model":
1322		if len(parts) >= 2 {
1323			i.applyModelSelection("", parts[1])
1324		} else {
1325			i.modelDialog.Open(i.cfg.Model)
1326		}
1327	case "/sessions":
1328		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1329	case "/jump":
1330		i.openJumpDialog(parts[1:])
1331	case "/btw":
1332		i.openBtwDialog(parts[1:])
1333	case "/skills":
1334		i.openSkillsDialog()
1335	case "/compact":
1336		i.runCompact(ctx, false)
1337	case "/jail":
1338		if i.cfg.Sandbox == nil {
1339			i.mu.Lock()
1340			i.statusErr = "sandbox not available in this build"
1341			i.mu.Unlock()
1342			break
1343		}
1344		i.cfg.Sandbox.Lock()
1345		i.mu.Lock()
1346		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1347		i.statusErr = ""
1348		i.mu.Unlock()
1349	case "/unjail":
1350		if i.cfg.Sandbox == nil {
1351			i.mu.Lock()
1352			i.statusErr = "sandbox not available in this build"
1353			i.mu.Unlock()
1354			break
1355		}
1356		i.cfg.Sandbox.Unlock()
1357		i.mu.Lock()
1358		i.statusOK = "unjailed"
1359		i.statusErr = ""
1360		i.mu.Unlock()
1361	case "/reload-ext":
1362		i.runReloadExt(ctx)
1363	case "/yolo":
1364		i.runYoloOn()
1365	case "/telegram", "/tg":
1366		if len(parts) >= 2 {
1367			i.doTelegram(parts[1])
1368			break
1369		}
1370		i.openTelegramDialog()
1371	case "/session":
1372		if len(parts) >= 2 {
1373			action := parts[1]
1374			arg := ""
1375			if len(parts) >= 3 {
1376				arg = strings.Join(parts[2:], " ")
1377			}
1378			i.doSessionOp(action, arg)
1379			break
1380		}
1381		i.openSessionOpsDialog()
1382	default:
1383		// Last-resort fallback: try the extension manager. Built-in
1384		// cases above always win; this branch only fires for slash
1385		// commands the extension manager registered. Same routing as
1386		// the editor's submit-handler dispatch path so the autocomplete
1387		// "enter on highlighted suggestion" flow also works.
1388		extName := strings.TrimPrefix(parts[0], "/")
1389		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1390			rest := ""
1391			if len(parts) > 1 {
1392				rest = strings.Join(parts[1:], " ")
1393			}
1394			go i.invokeExtensionCommand(ctx, extName, rest)
1395			return false
1396		}
1397		i.mu.Lock()
1398		i.statusErr = "unknown command: " + parts[0]
1399		i.mu.Unlock()
1400	}
1401	return false
1402}
1403
1404// openLogoutDialog shows the provider picker for `/logout` with no
1405// argument. Only providers the user is currently logged into are
1406// listed, plus an "all" entry when more than one is present. If
1407// nothing's logged in, writes a status line instead of opening an
1408// empty dialog.
1409func (i *Interactive) openLogoutDialog() {
1410	if i.cfg.AuthManager == nil {
1411		i.mu.Lock()
1412		i.statusErr = "no auth manager configured"
1413		i.mu.Unlock()
1414		i.invalidate()
1415		return
1416	}
1417	store := i.cfg.AuthManager.Store()
1418	if store == nil {
1419		i.mu.Lock()
1420		i.statusErr = "auth store is not available"
1421		i.mu.Unlock()
1422		i.invalidate()
1423		return
1424	}
1425	creds, err := store.Load()
1426	if err != nil {
1427		i.mu.Lock()
1428		i.statusErr = "read auth store: " + err.Error()
1429		i.mu.Unlock()
1430		i.invalidate()
1431		return
1432	}
1433
1434	var items []logoutItem
1435	for _, p := range []string{"anthropic", "openai"} {
1436		if creds.Has(p) {
1437			items = append(items, logoutItem{
1438				label:  p,
1439				target: p,
1440				method: creds.Method(p),
1441			})
1442		}
1443	}
1444	if len(items) == 0 {
1445		i.mu.Lock()
1446		i.statusOK = "no credentials stored; already logged out"
1447		i.statusErr = ""
1448		i.mu.Unlock()
1449		i.invalidate()
1450		return
1451	}
1452	if len(items) > 1 {
1453		items = append(items, logoutItem{label: "all", target: "all"})
1454	}
1455
1456	i.logoutDialog.Open(items)
1457	i.invalidate()
1458}
1459
1460// doLogout clears credentials for the given provider (or all providers)
1461// from auth.json. If the active agent was using those credentials, it
1462// is torn down so the user is forced through /login before their next
1463// prompt.
1464//
1465// target: "anthropic" | "openai" | "all"
1466func (i *Interactive) doLogout(target string) {
1467	if i.cfg.AuthManager == nil {
1468		i.mu.Lock()
1469		i.statusErr = "no auth manager configured"
1470		i.mu.Unlock()
1471		return
1472	}
1473	store := i.cfg.AuthManager.Store()
1474	if store == nil {
1475		i.mu.Lock()
1476		i.statusErr = "auth store is not available"
1477		i.mu.Unlock()
1478		return
1479	}
1480
1481	var providers []string
1482	switch target {
1483	case "", "all":
1484		providers = []string{"anthropic", "openai"}
1485	case "anthropic", "openai":
1486		providers = []string{target}
1487	default:
1488		i.mu.Lock()
1489		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1490		i.mu.Unlock()
1491		return
1492	}
1493
1494	var errs []string
1495	clearedCurrent := false
1496	for _, p := range providers {
1497		if err := store.Clear(p); err != nil {
1498			errs = append(errs, p+": "+err.Error())
1499			continue
1500		}
1501		if p == i.cfg.Provider {
1502			clearedCurrent = true
1503		}
1504	}
1505
1506	i.mu.Lock()
1507	defer i.mu.Unlock()
1508	if len(errs) > 0 {
1509		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1510		return
1511	}
1512	i.statusErr = ""
1513	if clearedCurrent {
1514		// The running agent was using a credential we just wiped. Drop
1515		// it so prompts can't go out with the stale client, and hint at
1516		// /login.
1517		i.agent = nil
1518		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1519	} else {
1520		i.statusOK = "logged out of " + strings.Join(providers, ", ")
1521	}
1522}
1523
1524func (i *Interactive) startAPIKeyFlow(provider string) {
1525	url, err := i.cfg.AuthManager.StartAPIKey(provider)
1526	if err != nil {
1527		i.dialog.ShowResult(false, err.Error())
1528		return
1529	}
1530	i.dialog.ShowWaiting(url)
1531}
1532
1533func (i *Interactive) startOAuthFlow(provider string) {
1534	url, err := i.cfg.AuthManager.StartOAuth(provider)
1535	if err != nil {
1536		i.dialog.ShowResult(false, err.Error())
1537		return
1538	}
1539	i.dialog.ShowWaiting(url)
1540}
1541
1542// applyModelSelection switches the active model (and provider, if the
1543// new model belongs to a different one). It rebuilds the underlying
1544// client when needed so the provider wire-protocol matches.
1545// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1546// briefly until the turn goroutine has updated i.busy = false. Used
1547// before destructive slash commands so transcript-mutating work
1548// (/clear, /compact, /logout, /login completion, cross-provider
1549// /model swap) doesn't race with the still-running stream.
1550//
1551// The wait is bounded; if the turn doesn't release within the timeout
1552// we proceed anyway. Worst case is a brief overlap that the agent's
1553// own mutex protects against.
1554func (i *Interactive) cancelAndWaitForIdle() {
1555	i.mu.Lock()
1556	busy := i.busy
1557	cancel := i.cancelTurn
1558	i.mu.Unlock()
1559	if !busy {
1560		return
1561	}
1562	if cancel != nil {
1563		cancel()
1564	}
1565	deadline := time.Now().Add(2 * time.Second)
1566	for time.Now().Before(deadline) {
1567		i.mu.Lock()
1568		done := !i.busy
1569		i.mu.Unlock()
1570		if done {
1571			return
1572		}
1573		time.Sleep(10 * time.Millisecond)
1574	}
1575}
1576
1577// openBtwDialog opens the side-chat overlay with a frozen snapshot
1578// of the current main session. The optional argument is auto-
1579// submitted as the first question, so '/btw does X work?' fires the
1580// model call immediately instead of just opening an empty dialog.
1581func (i *Interactive) openBtwDialog(args []string) {
1582	if i.agent == nil {
1583		i.mu.Lock()
1584		i.statusErr = "not logged in. type /login first."
1585		i.mu.Unlock()
1586		return
1587	}
1588	seed := strings.TrimSpace(strings.Join(args, " "))
1589	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1590	i.invalidate()
1591}
1592
1593// openSkillsDialog opens the skill inspector. The picker reflects
1594// whatever SkillSnapshot returns at call time, so edits to a
1595// SKILL.md made during a session show up on the next /skills.
1596func (i *Interactive) openSkillsDialog() {
1597	var list []*skills.Skill
1598	if i.cfg.SkillSnapshot != nil {
1599		list = i.cfg.SkillSnapshot()
1600	}
1601	i.skillsDialog.Open(list)
1602	i.invalidate()
1603}
1604
1605// openJumpDialog builds a /jump picker from the current transcript.
1606// If the user typed "/jump foo" with a filter and it matches exactly
1607// one turn, jump there directly without showing the dialog.
1608func (i *Interactive) openJumpDialog(args []string) {
1609	if i.view == nil || len(i.view.Messages) == 0 {
1610		i.mu.Lock()
1611		i.statusErr = "nothing to jump to \u2014 the session is empty"
1612		i.mu.Unlock()
1613		return
1614	}
1615	filter := strings.TrimSpace(strings.Join(args, " "))
1616	i.jumpDialog.Open(i.view.Messages, filter)
1617	// Shortcut: with a filter argument that matches exactly one turn,
1618	// jump immediately and skip the picker.
1619	if filter != "" {
1620		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1621			t := tgts[0]
1622			i.jumpDialog.Close()
1623			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1624		}
1625	}
1626}
1627
1628// applyJumpSelection scrolls the chat viewport so the user message at
1629// msgIdx is visible at (or near) the top of the chat area. Uses the
1630// anchor slice returned by view.BuildWithAnchors so the mapping from
1631// message index to row is exact, regardless of variable-height tool
1632// blocks above the target.
1633func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1634	cols := i.lastCols()
1635	chat, anchors := i.view.BuildWithAnchors(cols)
1636	var row int
1637	found := false
1638	for _, a := range anchors {
1639		if a.MessageIdx == msgIdx {
1640			row = a.Row
1641			found = true
1642			break
1643		}
1644	}
1645	if !found {
1646		i.mu.Lock()
1647		i.statusErr = "could not resolve jump target"
1648		i.mu.Unlock()
1649		return
1650	}
1651
1652	chatLen := len(chat)
1653	page := i.chatPage()
1654	if page < 1 {
1655		page = 1
1656	}
1657	// scrollOffset is measured from the bottom of the chat slice, so
1658	// to place `row` at the top of the viewport we want:
1659	//     chatLen - scrollOffset - page == row
1660	// Solve for scrollOffset and clamp to [0, chatLen-page].
1661	offset := chatLen - (row + page)
1662	if offset < 0 {
1663		offset = 0
1664	}
1665	maxOffset := chatLen - page
1666	if maxOffset < 0 {
1667		maxOffset = 0
1668	}
1669	if offset > maxOffset {
1670		offset = maxOffset
1671	}
1672
1673	i.mu.Lock()
1674	i.scrollOffset = offset
1675	i.parkedTurn = turnNo
1676	i.parkedTotal = totalTurnsLocked(i.view.Messages)
1677	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1678	i.statusErr = ""
1679	i.mu.Unlock()
1680}
1681
1682// totalTurnsLocked counts user messages in the transcript. Caller is
1683// assumed to hold i.mu (the name is a mild reminder; this function
1684// itself doesn't touch shared state beyond the slice it's handed).
1685func totalTurnsLocked(msgs []provider.Message) int {
1686	n := 0
1687	for _, m := range msgs {
1688		if m.Role == provider.RoleUser {
1689			n++
1690		}
1691	}
1692	return n
1693}
1694
1695// applySessionSelection loads the given session via the cli-provided
1696// callback and parks the viewport on the last turn so the user lands
1697// looking at where the conversation left off (their last prompt at the
1698// top of the chat, the assistant's last reply right below). Older
1699// history is one scroll up; pgdn or end snaps to the current tail.
1700//
1701// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1702// which on a long resumed session showed only the last few rows of
1703// the final assistant message — the user read that as "only one liner
1704// happened, the resume didn't work".
1705func (i *Interactive) applySessionSelection(path string) {
1706	if i.cfg.LoadSession == nil {
1707		i.mu.Lock()
1708		i.statusErr = "session loading is not wired in this build"
1709		i.mu.Unlock()
1710		return
1711	}
1712	if err := i.cfg.LoadSession(path); err != nil {
1713		i.mu.Lock()
1714		i.statusErr = err.Error()
1715		i.mu.Unlock()
1716		return
1717	}
1718
1719	i.mu.Lock()
1720	i.statusOK = "resumed session: " + path
1721	i.statusErr = ""
1722	i.parkedTurn = 0
1723	i.parkedTotal = 0
1724	i.view.InvalidateRenderCache()
1725	// Pull the freshly-loaded transcript into the view so the anchor
1726	// math below sees the post-resume messages, not the empty pre-load
1727	// state. redraw() does the same on its next pass; we just front-run
1728	// it here to compute the scroll target.
1729	if i.agent != nil {
1730		i.view.Messages = i.agent.Messages()
1731	}
1732	msgs := i.view.Messages
1733	i.mu.Unlock()
1734
1735	i.scrollToLastTurn(msgs)
1736}
1737
1738// scrollToLastTurn parks the viewport at the most recent user turn,
1739// or at the top if the transcript has no user messages. Used after
1740// resume so the user lands looking at where they left off.
1741func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1742	if len(msgs) == 0 {
1743		i.mu.Lock()
1744		i.scrollOffset = 0
1745		i.mu.Unlock()
1746		return
1747	}
1748	// Find the last user message index.
1749	lastUser := -1
1750	turnNo, totalTurns := 0, 0
1751	for idx, m := range msgs {
1752		if m.Role == provider.RoleUser {
1753			totalTurns++
1754			lastUser = idx
1755		}
1756	}
1757	if lastUser < 0 {
1758		i.mu.Lock()
1759		i.scrollOffset = 0
1760		i.mu.Unlock()
1761		return
1762	}
1763	turnNo = totalTurns
1764
1765	cols := i.lastCols()
1766	chat, anchors := i.view.BuildWithAnchors(cols)
1767	var row int
1768	found := false
1769	for _, a := range anchors {
1770		if a.MessageIdx == lastUser {
1771			row = a.Row
1772			found = true
1773			break
1774		}
1775	}
1776	if !found {
1777		i.mu.Lock()
1778		i.scrollOffset = 0
1779		i.mu.Unlock()
1780		return
1781	}
1782
1783	chatLen := len(chat)
1784	page := i.chatPage()
1785	if page < 1 {
1786		page = 1
1787	}
1788	offset := chatLen - (row + page)
1789	if offset < 0 {
1790		offset = 0
1791	}
1792	maxOffset := chatLen - page
1793	if maxOffset < 0 {
1794		maxOffset = 0
1795	}
1796	if offset > maxOffset {
1797		offset = maxOffset
1798	}
1799
1800	i.mu.Lock()
1801	i.scrollOffset = offset
1802	// Mark the parked-turn footer so the user sees "viewing turn N of
1803	// M · pgdn to catch up" — same affordance as /jump. Tells them at
1804	// a glance that they're looking at history, not the live tail.
1805	if offset > 0 {
1806		i.parkedTurn = turnNo
1807		i.parkedTotal = totalTurns
1808	}
1809	i.mu.Unlock()
1810	i.invalidate()
1811}
1812
1813func (i *Interactive) applyModelSelection(prov, model string) {
1814	if model == "" {
1815		return
1816	}
1817	m, err := provider.FindModel(prov, model)
1818	if err != nil {
1819		i.mu.Lock()
1820		i.statusErr = err.Error()
1821		i.mu.Unlock()
1822		return
1823	}
1824	// Same provider? Just swap the model on the existing agent.
1825	if i.agent != nil && m.Provider == i.cfg.Provider {
1826		i.mu.Lock()
1827		i.cfg.Model = m.ID
1828		i.agent.Model = m.ID
1829		i.statusOK = "model: " + m.ID
1830		i.statusErr = ""
1831		i.mu.Unlock()
1832		if i.cfg.PersistModel != nil {
1833			i.cfg.PersistModel(i.cfg.Provider, m.ID)
1834		}
1835		return
1836	}
1837	// Different provider: rebuild agent (needs credentials for target provider).
1838	if i.cfg.BuildAgentFor == nil {
1839		i.mu.Lock()
1840		i.statusErr = "cannot switch provider: no builder configured"
1841		i.mu.Unlock()
1842		return
1843	}
1844	// Snapshot the current transcript and cumulative usage BEFORE we
1845	// build the replacement agent so we can hand them off. Without
1846	// this the user perceives the entire session as wiped on a
1847	// cross-provider /model swap.
1848	var carryMsgs []provider.Message
1849	var carryCost provider.Usage
1850	if i.agent != nil {
1851		carryMsgs = i.agent.Messages()
1852		carryCost = i.agent.Cost()
1853	}
1854
1855	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1856	if err != nil {
1857		i.mu.Lock()
1858		i.statusErr = err.Error()
1859		i.mu.Unlock()
1860		return
1861	}
1862
1863	// Replay the transcript and seed the cost on the freshly-built
1864	// agent. Messages travel cleanly between providers because they
1865	// use the same provider.Message shape; tool-call ids are local
1866	// to a turn so cross-provider continuation never confuses the
1867	// new model (it just sees the assistant's reply, no orphan
1868	// tool_use blocks because /model swaps are gated to idle state).
1869	if len(carryMsgs) > 0 {
1870		ag.SetMessages(carryMsgs)
1871	}
1872	ag.SeedCost(carryCost)
1873
1874	i.mu.Lock()
1875	i.agent = ag
1876	i.cfg.Provider = p
1877	i.cfg.Model = md
1878	i.statusOK = "switched to " + p + " / " + md
1879	i.statusErr = ""
1880	// Render cache keys are width+content based, so the new agent's
1881	// identical messages will reuse the existing entries. Nothing
1882	// to invalidate.
1883	i.mu.Unlock()
1884	if i.cfg.PersistModel != nil {
1885		i.cfg.PersistModel(p, md)
1886	}
1887}
1888
1889func (i *Interactive) handleAuthEvent(ev auth.Event) {
1890	switch ev.Kind {
1891	case "started":
1892		i.dialog.ShowWaiting(ev.URL)
1893	case "browser_open":
1894		// no-op
1895	case "error":
1896		i.dialog.ShowResult(false, ev.Message)
1897	case "success":
1898		// Rebuild the agent with the fresh credential.
1899		ag, prov, model, err := i.cfg.BuildAgent()
1900		if err != nil {
1901			i.dialog.ShowResult(false, err.Error())
1902			return
1903		}
1904		i.mu.Lock()
1905		i.agent = ag
1906		i.cfg.Provider = prov
1907		i.cfg.Model = model
1908		i.statusErr = ""
1909		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1910		i.mu.Unlock()
1911		i.dialog.ShowResult(true, "")
1912	}
1913}
1914
1915// runCompact invokes core.Agent.Compact and reflects the progress in
1916// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1917// cancel via the same cancelTurn channel used for normal turns.
1918//
1919// When auto is true the spinner message is pinned to "condensing
1920// history" and the status bar surfaces "(auto)" next to the context
1921// percentage so it's obvious the system triggered this, not the user.
1922func (i *Interactive) runCompact(parent context.Context, auto bool) {
1923	if i.agent == nil {
1924		i.mu.Lock()
1925		i.statusErr = "not logged in. type /login first."
1926		i.mu.Unlock()
1927		return
1928	}
1929	ctx, cancel := context.WithCancel(parent)
1930	i.mu.Lock()
1931	i.busy = true
1932	if auto {
1933		i.spin.StartFixed("condensing history")
1934		i.autoCompacting = true
1935		i.statusOK = "condensing history… (esc to cancel)"
1936	} else {
1937		i.spin.Start()
1938		i.statusOK = "compacting..."
1939	}
1940	i.cancelTurn = cancel
1941	i.statusErr = ""
1942	i.streaming.Reset()
1943	i.streamOn = true
1944	i.scrollOffset = 0
1945	i.helpBlock = nil
1946	i.mu.Unlock()
1947	i.invalidate()
1948
1949	go func() {
1950		sink := func(delta string) {
1951			i.mu.Lock()
1952			i.streaming.WriteString(delta)
1953			i.mu.Unlock()
1954			i.invalidate()
1955		}
1956		summary, err := i.agent.Compact(ctx, 4, sink)
1957		i.mu.Lock()
1958		i.busy = false
1959		i.streamOn = false
1960		i.streaming.Reset()
1961		i.cancelTurn = nil
1962		i.autoCompacting = false
1963		switch {
1964		case err != nil && ctx.Err() != nil:
1965			i.statusErr = ""
1966			if auto {
1967				i.statusOK = "auto-condense cancelled"
1968			} else {
1969				i.statusOK = "compaction cancelled"
1970			}
1971		case err != nil:
1972			i.statusErr = "compaction failed: " + err.Error()
1973			i.statusOK = ""
1974		default:
1975			i.statusErr = ""
1976			i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1977			i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1978			i.toolCalls = map[string]*tui.ToolCallView{}
1979			i.toolOrder = nil
1980			// Transcript was rewritten in place — purge the per-message
1981			// render cache so stale entries keyed on the old messages
1982			// don't linger.
1983			i.view.InvalidateRenderCache()
1984		}
1985		i.mu.Unlock()
1986		i.invalidate()
1987	}()
1988}
1989
1990func (i *Interactive) startTurn(parent context.Context, prompt string) {
1991	if i.agent == nil {
1992		return
1993	}
1994	ctx, cancel := context.WithCancel(parent)
1995	i.mu.Lock()
1996	i.busy = true
1997	i.spin.Start()
1998	i.cancelTurn = cancel
1999	i.statusErr = ""
2000	i.statusOK = ""
2001	i.streaming.Reset()
2002	i.streamOn = true
2003	i.toolCalls = map[string]*tui.ToolCallView{}
2004	i.toolOrder = nil
2005	i.scrollOffset = 0 // jump back to the bottom on new turn
2006	i.parkedTurn = 0   // starting a turn clears the /jump parked state
2007	i.parkedTotal = 0
2008	i.helpBlock = nil // hide the help block once the user asks something
2009	i.mu.Unlock()
2010	i.invalidate()
2011
2012	sink := func(ev core.AgentEvent) {
2013		i.handleEvent(ev)
2014		i.invalidate()
2015	}
2016
2017	go func() {
2018		err := i.agent.Prompt(ctx, prompt, nil, sink)
2019		i.mu.Lock()
2020		i.busy = false
2021		i.streamOn = false
2022		i.cancelTurn = nil
2023		if err != nil && ctx.Err() == nil {
2024			i.statusErr = err.Error()
2025		}
2026		// Pop the next queued message, if any, and relaunch.
2027		var next string
2028		var hasNext bool
2029		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
2030			next, i.queued = i.queued[0], i.queued[1:]
2031			hasNext = true
2032		}
2033		// If the turn was cancelled or errored, drop the queue so the
2034		// user isn't bombarded with stale messages after an interrupt.
2035		if ctx.Err() != nil || err != nil {
2036			i.queued = nil
2037		}
2038		// Decide whether the next thing to do is an auto-compaction.
2039		// Only fires when the turn completed cleanly AND the queue is
2040		// empty (otherwise a queued message would race the condense).
2041		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
2042		i.mu.Unlock()
2043		i.invalidate()
2044		parent := i.runCtx
2045		if parent == nil {
2046			parent = context.Background()
2047		}
2048		switch {
2049		case hasNext:
2050			i.startTurn(parent, next)
2051		case shouldAutoCompact:
2052			i.runCompact(parent, true)
2053		}
2054	}()
2055}
2056
2057// autoCompactThreshold is the context-window fraction at which the
2058// agent will auto-compact after a turn ends. 0.85 leaves enough
2059// headroom for one more user prompt + response before we bump the
2060// hard limit.
2061const autoCompactThreshold = 0.85
2062
2063// shouldAutoCompactLocked reports whether the last turn pushed context
2064// usage past the auto-compact threshold. Must be called with i.mu
2065// held; it reads lastCtxInput and the current model's context window.
2066func (i *Interactive) shouldAutoCompactLocked() bool {
2067	if i.agent == nil {
2068		return false
2069	}
2070	if i.autoCompacting {
2071		return false
2072	}
2073	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
2074	if err != nil || m.ContextWindow <= 0 {
2075		return false
2076	}
2077	if i.lastCtxInput <= 0 {
2078		return false
2079	}
2080	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
2081}
2082
2083func (i *Interactive) handleEvent(ev core.AgentEvent) {
2084	i.mu.Lock()
2085	defer i.mu.Unlock()
2086	switch e := ev.(type) {
2087	case core.EvTextDelta:
2088		i.streaming.WriteString(e.Delta)
2089	case core.EvAssistantMessage:
2090		i.streaming.Reset()
2091		i.streamOn = false
2092		if i.cfg.OnAssistant != nil {
2093			i.cfg.OnAssistant(e.Message)
2094		}
2095		// Mirror the assistant's final visible text to the telegram
2096		// bridge when active. Only TextBlock content is forwarded;
2097		// tool_use blocks are internal. Send on a goroutine so the
2098		// network call doesn't hold the event loop lock.
2099		if i.telegramBridge != nil && i.telegramBridge.Active() {
2100			var sb strings.Builder
2101			for _, c := range e.Message.Content {
2102				if tb, ok := c.(provider.TextBlock); ok {
2103					if sb.Len() > 0 {
2104						sb.WriteString("\n")
2105					}
2106					sb.WriteString(tb.Text)
2107				}
2108			}
2109			if text := sb.String(); strings.TrimSpace(text) != "" {
2110				go i.telegramBridge.OnAssistantText(text)
2111			}
2112		}
2113	case core.EvToolUseStart:
2114		// Live streaming: pre-create the view so the user sees the
2115		// tool call being composed in real time. Any subsequent
2116		// EvToolCall for the same ID updates the same struct (the
2117		// final parsed args + name are already known here).
2118		if _, exists := i.toolCalls[e.ID]; !exists {
2119			i.toolCalls[e.ID] = &tui.ToolCallView{
2120				ID:        e.ID,
2121				Name:      e.Name,
2122				Streaming: true,
2123			}
2124			i.toolOrder = append(i.toolOrder, e.ID)
2125		}
2126	case core.EvToolUseArgs:
2127		if tc, ok := i.toolCalls[e.ID]; ok {
2128			tc.RawJSONBuf += e.Delta
2129			// Refresh the live path as soon as it parses; used in
2130			// the header (▸ write /Users/pat/Desktop/demo.ts)
2131			// while the content is still streaming.
2132			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2133				tc.LivePath = p
2134			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2135				tc.LivePath = p
2136			}
2137		}
2138	case core.EvToolUseEnd:
2139		if tc, ok := i.toolCalls[e.ID]; ok {
2140			tc.Streaming = false
2141		}
2142	case core.EvToolCall:
2143		// If we already pre-created the view during streaming, just
2144		// refresh the final Args summary. Otherwise create a new one
2145		// (non-streaming providers or legacy paths).
2146		if tc, ok := i.toolCalls[e.ID]; ok {
2147			tc.Args = shortArgs(e.Args)
2148			tc.Streaming = false
2149		} else {
2150			i.toolCalls[e.ID] = &tui.ToolCallView{
2151				ID:   e.ID,
2152				Name: e.Name,
2153				Args: shortArgs(e.Args),
2154			}
2155			i.toolOrder = append(i.toolOrder, e.ID)
2156		}
2157	case core.EvToolResult:
2158		if tc, ok := i.toolCalls[e.ID]; ok {
2159			tc.Done = true
2160			tc.Error = e.Result.IsError
2161			var text strings.Builder
2162			for _, c := range e.Result.Content {
2163				if tb, ok := c.(provider.TextBlock); ok {
2164					if text.Len() > 0 {
2165						text.WriteString("\n")
2166					}
2167					text.WriteString(tb.Text)
2168				}
2169			}
2170			tc.Result = text.String()
2171		}
2172		if i.cfg.OnToolResult != nil {
2173			i.cfg.OnToolResult(e.ID, e.Result)
2174		}
2175	case core.EvUsage:
2176		i.cumUsage = e.Cumulative
2177		if e.Usage.InputTokens > 0 {
2178			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2179		}
2180	case core.EvTurnEnd:
2181		if e.Stop == provider.StopAborted {
2182			// Aborted turn: discard the partial streaming text (it is not
2183			// persisted in the transcript) and clear any transient error.
2184			i.streaming.Reset()
2185			i.streamOn = false
2186			i.statusErr = ""
2187			i.statusOK = "cancelled"
2188			return
2189		}
2190		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2191			i.statusErr = e.Err.Error()
2192		}
2193	}
2194}
2195
2196// Agent returns the current agent, if any. Used by cli.go to flush the
2197// final transcript to the session file.
2198func (i *Interactive) Agent() *core.Agent {
2199	i.mu.Lock()
2200	defer i.mu.Unlock()
2201	return i.agent
2202}
2203
2204func shortArgs(raw json.RawMessage) string {
2205	var v any
2206	if err := json.Unmarshal(raw, &v); err != nil {
2207		return ""
2208	}
2209	if m, ok := v.(map[string]any); ok {
2210		for _, k := range []string{"path", "file_path", "command"} {
2211			if s, ok := m[k].(string); ok {
2212				if len(s) > 60 {
2213					s = s[:57] + "..."
2214				}
2215				return s
2216			}
2217		}
2218	}
2219	b, _ := json.Marshal(v)
2220	s := string(b)
2221	if len(s) > 60 {
2222		s = s[:57] + "..."
2223	}
2224	return s
2225}
2226
2227// silence unused import in some build configs
2228var _ = fmt.Sprintf
2229
2230// runReloadExt triggers a live reload of every extension (discovered
2231// + explicit). Runs on a goroutine so the TUI stays responsive; the
2232// Manager.Reload takes a couple of hundred ms to shut down subprocs
2233// and respawn them. Shows a status line throughout.
2234func (i *Interactive) runReloadExt(ctx context.Context) {
2235	if i.cfg.Extensions == nil {
2236		i.mu.Lock()
2237		i.statusErr = "no extension manager in this build"
2238		i.mu.Unlock()
2239		i.invalidate()
2240		return
2241	}
2242	i.mu.Lock()
2243	i.statusOK = "reloading extensions…"
2244	i.statusErr = ""
2245	i.mu.Unlock()
2246	i.invalidate()
2247
2248	go func() {
2249		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2250		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2251		if len(stats.Errors) > 0 {
2252			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2253		}
2254		i.mu.Lock()
2255		i.statusOK = msg
2256		i.statusErr = ""
2257		i.mu.Unlock()
2258		i.invalidate()
2259	}()
2260}
2261
2262// Confirm implements core.Confirmer. The agent goroutine calls
2263// this synchronously before every tool invocation when --no-yolo is
2264// active. We push the request onto the confirmDialog queue, trigger
2265// a redraw, and block the caller until the user answers.
2266//
2267// If the session is cancelled or the TUI exits mid-prompt, any
2268// pending request is refused via CancelAll so the agent doesn't
2269// deadlock.
2270func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2271	resp := make(chan core.ConfirmDecision, 1)
2272	i.confirmDialog.Enqueue(&confirmRequest{
2273		toolName: toolName,
2274		preview:  preview,
2275		resp:     resp,
2276	})
2277	i.invalidate()
2278	return <-resp
2279}
2280
2281// runYoloOn disables --no-yolo for the rest of the session. Tool
2282// calls run without prompting after this; there's intentionally no
2283// way to re-enable gating mid-session, if the user wants that back
2284// they can exit and restart with --no-yolo.
2285func (i *Interactive) runYoloOn() {
2286	if i.cfg.ConfirmGate == nil {
2287		i.mu.Lock()
2288		i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2289		i.statusErr = ""
2290		i.mu.Unlock()
2291		i.invalidate()
2292		return
2293	}
2294	i.cfg.ConfirmGate.AllowAll()
2295	// Also auto-allow any currently pending confirmation so the
2296	// agent doesn't deadlock if /yolo is typed while a prompt is
2297	// open.
2298	i.confirmDialog.AllowAllPending()
2299	i.mu.Lock()
2300	i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2301	i.statusErr = ""
2302	i.mu.Unlock()
2303	i.invalidate()
2304}
2305
2306// openTelegramDialog shows the picker for `/telegram` with no arg.
2307// Items depend on current state: disconnect + status when running,
2308// connect + status when stopped.
2309func (i *Interactive) openTelegramDialog() {
2310	items := i.telegramMenuItems()
2311	if len(items) == 0 {
2312		i.mu.Lock()
2313		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2314		i.mu.Unlock()
2315		i.invalidate()
2316		return
2317	}
2318	i.telegramDialog.Open(items)
2319	i.invalidate()
2320}
2321
2322// telegramMenuItems builds the dialog entries for the current
2323// bridge state. Returns empty when no bot.json exists so the
2324// caller can show a helpful status line instead of an empty menu.
2325func (i *Interactive) telegramMenuItems() []telegramItem {
2326	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2327	if err != nil || cfg.BotToken == "" {
2328		return nil
2329	}
2330	var items []telegramItem
2331	if i.telegramBridge != nil && i.telegramBridge.Active() {
2332		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2333		st := i.telegramBridge.State()
2334		hint := "active"
2335		if st.Username != "" {
2336			hint += " as @" + st.Username
2337		}
2338		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2339	} else {
2340		label := "connect"
2341		hint := "start mirroring dms into this session"
2342		if cfg.AllowedUserID == 0 {
2343			hint = "awaiting pairing (send /start to the bot once connected)"
2344		}
2345		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2346		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2347	}
2348	return items
2349}
2350
2351// doTelegram dispatches one of the three explicit actions. Called
2352// from /telegram <action> or after the picker selects a row.
2353func (i *Interactive) doTelegram(action string) {
2354	switch action {
2355	case "connect":
2356		i.telegramConnect()
2357	case "disconnect":
2358		i.telegramDisconnect()
2359	case "status":
2360		i.telegramStatus()
2361	default:
2362		i.mu.Lock()
2363		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2364		i.mu.Unlock()
2365		i.invalidate()
2366	}
2367}
2368
2369// telegramConnect starts the bridge. Refuses if it's already
2370// running or if the on-disk bot.json is missing a token.
2371func (i *Interactive) telegramConnect() {
2372	if i.telegramBridge != nil && i.telegramBridge.Active() {
2373		i.mu.Lock()
2374		i.statusOK = "telegram already connected"
2375		i.statusErr = ""
2376		i.mu.Unlock()
2377		i.invalidate()
2378		return
2379	}
2380	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2381	if err != nil {
2382		i.mu.Lock()
2383		i.statusErr = "telegram: " + err.Error()
2384		i.mu.Unlock()
2385		i.invalidate()
2386		return
2387	}
2388	if cfg.BotToken == "" {
2389		i.mu.Lock()
2390		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2391		i.mu.Unlock()
2392		i.invalidate()
2393		return
2394	}
2395	// Refuse to start when a background daemon is already polling
2396	// the same bot. Two concurrent long-poll consumers race each
2397	// update and one always loses, so DMs get half-delivered. The
2398	// user can `zot telegram-bot stop` first, then /telegram connect.
2399	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2400		i.mu.Lock()
2401		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2402		i.mu.Unlock()
2403		i.invalidate()
2404		return
2405	}
2406	i.telegramBridge = &telegram.Bridge{
2407		Client: telegram.NewClient(cfg.BotToken),
2408		Config: cfg,
2409		Save: func(next telegram.Config) error {
2410			return telegram.SaveConfig(i.cfg.ZotHome, next)
2411		},
2412		Host: &telegramHost{iv: i},
2413	}
2414	if err := i.telegramBridge.Start(i.runCtx); err != nil {
2415		i.telegramBridge = nil
2416		i.mu.Lock()
2417		i.statusErr = "telegram connect failed: " + err.Error()
2418		i.mu.Unlock()
2419		i.invalidate()
2420		return
2421	}
2422	state := i.telegramBridge.State()
2423	label := "telegram connected"
2424	if state.Username != "" {
2425		label += " as @" + state.Username
2426	}
2427	if state.PairedID == 0 {
2428		label += " — send /start to the bot from your phone to claim it"
2429	}
2430	i.mu.Lock()
2431	i.statusOK = label
2432	i.statusErr = ""
2433	i.mu.Unlock()
2434	i.invalidate()
2435}
2436
2437// telegramDisconnect stops the bridge. No-op when already stopped.
2438func (i *Interactive) telegramDisconnect() {
2439	if i.telegramBridge == nil || !i.telegramBridge.Active() {
2440		i.mu.Lock()
2441		i.statusOK = "telegram already disconnected"
2442		i.statusErr = ""
2443		i.mu.Unlock()
2444		i.invalidate()
2445		return
2446	}
2447	i.telegramBridge.Stop()
2448	i.mu.Lock()
2449	i.statusOK = "telegram disconnected"
2450	i.statusErr = ""
2451	i.mu.Unlock()
2452	i.invalidate()
2453}
2454
2455// telegramStatus writes a one-liner describing the bridge state.
2456// Reports on both the in-tui bridge and the background daemon so
2457// the user isn't confused when the daemon owns the poll loop.
2458func (i *Interactive) telegramStatus() {
2459	var msg string
2460	if i.telegramBridge != nil && i.telegramBridge.Active() {
2461		s := i.telegramBridge.State()
2462		msg = "telegram: connected (tui bridge)"
2463		if s.Username != "" {
2464			msg += " as @" + s.Username
2465		}
2466		if s.PairedID != 0 {
2467			msg += fmt.Sprintf(" · paired with user %d", s.PairedID)
2468		} else {
2469			msg += " · awaiting pairing"
2470		}
2471	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2472		msg = fmt.Sprintf("telegram: background daemon running (pid %d) · /telegram connect won't work until you stop it", pid)
2473	} else {
2474		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2475		if cfg.BotToken == "" {
2476			msg = "telegram: not configured. run `zot telegram-bot setup` first."
2477		} else {
2478			msg = "telegram: disconnected"
2479			if cfg.BotUsername != "" {
2480				msg += " (@" + cfg.BotUsername + " ready to connect)"
2481			}
2482		}
2483	}
2484	i.mu.Lock()
2485	i.statusOK = msg
2486	i.statusErr = ""
2487	i.mu.Unlock()
2488	i.invalidate()
2489}
2490
2491// telegramHost adapts *Interactive to telegram.Host so the bridge
2492// can call back into the TUI without importing modes directly.
2493type telegramHost struct{ iv *Interactive }
2494
2495func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2496	h.iv.SubmitOrQueue(prompt, images)
2497}
2498
2499func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2500
2501func (h *telegramHost) Notify(level, message string) {
2502	h.iv.mu.Lock()
2503	switch level {
2504	case "error", "warn":
2505		h.iv.statusErr = message
2506		h.iv.statusOK = ""
2507	default:
2508		h.iv.statusOK = message
2509		h.iv.statusErr = ""
2510	}
2511	h.iv.mu.Unlock()
2512	h.iv.invalidate()
2513}
2514
2515// openSessionOpsDialog shows the picker for `/session` with no arg.
2516// Always offers both export and import; the handlers bail with a
2517// clear status message when the precondition isn't met (empty
2518// transcript on export; missing file on import).
2519func (i *Interactive) openSessionOpsDialog() {
2520	items := []sessionOpsItem{
2521		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
2522		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2523	}
2524	i.sessionOpsDialog.Open(items)
2525	i.invalidate()
2526}
2527
2528// doSessionOp dispatches export or import. path is the optional
2529// positional argument from /session export <path> or /session
2530// import <path>; when empty, sensible defaults apply (see
2531// doSessionExport / doSessionImport).
2532func (i *Interactive) doSessionOp(action, path string) {
2533	switch action {
2534	case "export":
2535		i.doSessionExport(path)
2536	case "import":
2537		i.doSessionImport(path)
2538	default:
2539		i.mu.Lock()
2540		i.statusErr = "unknown /session action: " + action + " (use export or import)"
2541		i.mu.Unlock()
2542		i.invalidate()
2543	}
2544}
2545
2546// doSessionExport writes the live session file to destination path
2547// dst. When dst is empty we default to ~/Downloads (falling back to
2548// the user's home directory if it doesn't exist). The helper
2549// expands a leading `~` and creates any missing parent directories.
2550func (i *Interactive) doSessionExport(dst string) {
2551	if i.cfg.CurrentSessionPath == nil {
2552		i.mu.Lock()
2553		i.statusErr = "export: no session is active (running with --no-session?)"
2554		i.mu.Unlock()
2555		i.invalidate()
2556		return
2557	}
2558	src := i.cfg.CurrentSessionPath()
2559	if src == "" {
2560		i.mu.Lock()
2561		i.statusErr = "export: no session is active (running with --no-session?)"
2562		i.mu.Unlock()
2563		i.invalidate()
2564		return
2565	}
2566	dst = strings.TrimSpace(dst)
2567	if dst == "" {
2568		dst = defaultExportDir()
2569	} else {
2570		dst = expandTilde(dst)
2571	}
2572	out, err := core.ExportSession(src, dst)
2573	if err != nil {
2574		i.mu.Lock()
2575		i.statusErr = "export: " + err.Error()
2576		i.mu.Unlock()
2577		i.invalidate()
2578		return
2579	}
2580	i.mu.Lock()
2581	i.statusOK = "exported session to " + friendlyPath(out)
2582	i.statusErr = ""
2583	i.mu.Unlock()
2584	i.invalidate()
2585}
2586
2587// doSessionImport copies the .zotsession file at src into the
2588// running cwd's sessions directory and loads it as the active
2589// session, same as `/sessions` -> pick. When src is empty we ask
2590// the user to pass a path (no usable default here).
2591func (i *Interactive) doSessionImport(src string) {
2592	src = strings.TrimSpace(src)
2593	if src == "" {
2594		i.mu.Lock()
2595		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
2596		i.mu.Unlock()
2597		i.invalidate()
2598		return
2599	}
2600	src = expandTilde(src)
2601	if _, err := os.Stat(src); err != nil {
2602		i.mu.Lock()
2603		i.statusErr = "import: " + err.Error()
2604		i.mu.Unlock()
2605		i.invalidate()
2606		return
2607	}
2608	newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
2609	if err != nil {
2610		i.mu.Lock()
2611		i.statusErr = "import: " + err.Error()
2612		i.mu.Unlock()
2613		i.invalidate()
2614		return
2615	}
2616	if i.cfg.LoadSession == nil {
2617		i.mu.Lock()
2618		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
2619		i.statusErr = ""
2620		i.mu.Unlock()
2621		i.invalidate()
2622		return
2623	}
2624	if err := i.cfg.LoadSession(newPath); err != nil {
2625		i.mu.Lock()
2626		i.statusErr = "import: load failed: " + err.Error()
2627		i.mu.Unlock()
2628		i.invalidate()
2629		return
2630	}
2631	i.mu.Lock()
2632	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
2633	i.statusErr = ""
2634	i.mu.Unlock()
2635	i.invalidate()
2636}
2637
2638// defaultExportDir returns ~/Downloads when it exists, or ~ as a
2639// fallback, or /tmp on exotic machines with no home dir.
2640func defaultExportDir() string {
2641	home, err := os.UserHomeDir()
2642	if err != nil || home == "" {
2643		return os.TempDir()
2644	}
2645	downloads := filepath.Join(home, "Downloads")
2646	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
2647		return downloads
2648	}
2649	return home
2650}
2651
2652// expandTilde turns a leading ~ into the user's home directory.
2653// Returns the input unchanged when there's no tilde or no home.
2654func expandTilde(p string) string {
2655	if p == "" || p[0] != '~' {
2656		return p
2657	}
2658	home, err := os.UserHomeDir()
2659	if err != nil || home == "" {
2660		return p
2661	}
2662	if len(p) == 1 {
2663		return home
2664	}
2665	if p[1] == '/' || p[1] == filepath.Separator {
2666		return filepath.Join(home, p[2:])
2667	}
2668	return p
2669}
2670
2671// friendlyPath collapses the user's home directory to a leading ~
2672// so status messages read cleanly. Falls back to the raw path when
2673// the home dir is unknown.
2674func friendlyPath(p string) string {
2675	home, err := os.UserHomeDir()
2676	if err != nil || home == "" {
2677		return p
2678	}
2679	if strings.HasPrefix(p, home+string(filepath.Separator)) {
2680		return "~" + p[len(home):]
2681	}
2682	return p
2683}

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 477

↑↓ moveenter openesc close