v0.19.0 d7aa01a

interactive_rendering.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"sort"
6	"strings"
7	"time"
8
9	"github.com/bnema/zut/packages/agent/extproto"
10	"github.com/bnema/zut/packages/agent/skills"
11	"github.com/bnema/zut/packages/provider"
12	"github.com/bnema/zut/packages/tui"
13)
14
15func (i *Interactive) cachedChatLocked(cols int) []string {
16	// A busy frame contains mutable streaming/tool state. Keep the stable
17	// transcript cache, but never return a full-frame cache entry that would
18	// hide a newly arrived delta or completed result.
19	if i.busy || i.streamOn || i.streamFlushPending {
20		return i.buildChatLocked(cols)
21	}
22	key := i.chatCacheKeyLocked(cols)
23	if i.chatCacheValid && i.chatCacheKey == key {
24		return append([]string(nil), i.chatCache...)
25	}
26	chat := i.buildChatLocked(cols)
27	key = i.chatCacheKeyLocked(cols)
28	i.chatCache = append(i.chatCache[:0], chat...)
29	i.chatCacheKey = key
30	i.chatCacheValid = true
31	return chat
32}
33func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
34	var rev uint64
35	if i.agent != nil {
36		rev = i.agent.Revision()
37	}
38	showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
39	return chatCacheKey{
40		cols:                 cols,
41		agentRev:             rev,
42		statusOK:             i.statusOK,
43		statusErr:            i.statusErr,
44		help:                 strings.Join(i.helpBlock, "\n"),
45		extNotes:             strings.Join(i.extNotes, "\n"),
46		extStatuses:          i.extensionStatusesKeyLocked(),
47		extWidgets:           i.extensionWidgetsKeyLocked(),
48		reloadErrors:         strings.Join(i.reloadErrors, "\n"),
49		updateAvailable:      i.updateInfo.Available,
50		updateCurrent:        i.updateInfo.Current,
51		updateLatest:         i.updateInfo.Latest,
52		updateURL:            i.updateInfo.URL,
53		welcomeShowVer:       showVer,
54		expandAll:            i.view.ExpandAll,
55		tailLimit:            i.view.TailLimit,
56		renderedMessageCount: len(i.view.Messages),
57		viewCacheRev:         i.view.RenderCacheRevision,
58	}
59}
60func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
61	keys := make([]string, 0, len(groups))
62	for key := range groups {
63		keys = append(keys, key)
64	}
65	sort.Strings(keys)
66	return keys
67}
68func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
69	items := groups[outer]
70	keys := make([]string, 0, len(items))
71	for key := range items {
72		keys = append(keys, key)
73	}
74	sort.Strings(keys)
75	return keys
76}
77func (i *Interactive) extensionStatusesKeyLocked() string {
78	var sb strings.Builder
79	extNames := sortedNestedOuterKeys(i.extStatuses)
80	for _, extName := range extNames {
81		keys := sortedNestedInnerKeys(i.extStatuses, extName)
82		for _, key := range keys {
83			status := i.extStatuses[extName][key]
84			fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
85		}
86	}
87	return sb.String()
88}
89func (i *Interactive) extensionWidgetsKeyLocked() string {
90	var sb strings.Builder
91	extNames := sortedNestedOuterKeys(i.extWidgets)
92	for _, extName := range extNames {
93		ids := sortedNestedInnerKeys(i.extWidgets, extName)
94		for _, id := range ids {
95			widget := i.extWidgets[extName][id]
96			fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
97			for _, line := range widget.Lines {
98				sb.WriteString(line)
99				sb.WriteByte('\n')
100			}
101		}
102	}
103	return sb.String()
104}
105func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
106	var out []tui.RightBarWidget
107	for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
108		for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
109			widget := i.extWidgets[extName][id]
110			if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
111				continue
112			}
113			out = append(out, tui.RightBarWidget{
114				Extension: extName,
115				ID:        id,
116				Title:     widget.Title,
117				Lines:     append([]string(nil), widget.Lines...),
118			})
119		}
120	}
121	return out
122}
123func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
124	return i.extensionChromeLinesAtLocked(cols, false)
125}
126func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
127	lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
128	if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
129		return lines
130	}
131	limit := maxNarrowExtensionChromeRows
132	trimmed := append([]string(nil), lines[:limit-1]...)
133	trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine("  ... extension chrome truncated ...", cols)))
134	return trimmed
135}
136func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
137	var out []string
138	bodyWidth := cols - 2
139	if bodyWidth < 1 {
140		bodyWidth = 1
141	}
142	widgetRows := 0
143	widgetTruncated := false
144	appendWidgetRow := func(line string) bool {
145		if widgetRows >= maxExtensionWidgetRows-1 {
146			widgetTruncated = true
147			return false
148		}
149		out = append(out, line)
150		widgetRows++
151		return true
152	}
153
154	stopWidgets := false
155	for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
156		if stopWidgets {
157			break
158		}
159		for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
160			widget := i.extWidgets[extName][id]
161			if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
162				continue
163			}
164			label := "  [" + extName + "]"
165			if widget.Title != "" {
166				label += " " + widget.Title
167			}
168			if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
169				stopWidgets = true
170				break
171			}
172			for _, line := range widget.Lines {
173				if !appendWidgetRow("  " + truncateLine(line, bodyWidth)) {
174					stopWidgets = true
175					break
176				}
177			}
178			if stopWidgets {
179				break
180			}
181		}
182	}
183	if widgetTruncated {
184		out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine("  ... extension widgets truncated ...", cols)))
185	}
186
187	statusRows := 0
188	statusTruncated := false
189	for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
190		for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
191			if statusRows >= maxExtensionStatusRows-1 {
192				statusTruncated = true
193				break
194			}
195			status := i.extStatuses[extName][key]
196			color := i.cfg.Theme.Muted
197			switch status.Level {
198			case "warn":
199				color = i.cfg.Theme.Warning
200			case "error":
201				color = i.cfg.Theme.Error
202			case "success":
203				color = i.cfg.Theme.Tool
204			}
205			label := "  [" + extName + "] " + status.Text
206			out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
207			statusRows++
208		}
209		if statusTruncated {
210			break
211		}
212	}
213	if statusTruncated {
214		out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine("  ... extension statuses truncated ...", cols)))
215	}
216	return out
217}
218func (i *Interactive) stableChatRowsLocked(cols int) []string {
219	key := i.chatCacheKeyLocked(cols)
220	if i.stableChatCacheValid && i.stableChatCacheKey == key {
221		return append([]string(nil), i.stableChatCache...)
222	}
223
224	renderView := i.view.CloneForRender()
225	renderView.Streaming = ""
226	renderView.StreamingActive = false
227	renderView.ToolCalls = nil
228	renderView.Err = ""
229	if !i.renderOutsideLock {
230		rows := renderView.Build(cols)
231		i.stableChatCache = append(i.stableChatCache[:0], rows...)
232		i.stableChatCacheKey = key
233		i.stableChatCacheValid = true
234		return append([]string(nil), rows...)
235	}
236	// Markdown, syntax highlighting, and wrapping are the expensive part of
237	// a frame. Build the immutable snapshot without holding the interactive
238	// mutex so key processing can continue while a cold transcript renders.
239	i.mu.Unlock()
240	rows := renderView.Build(cols)
241	i.mu.Lock()
242	if i.view.MessagesRevision == renderView.MessagesRevision &&
243		i.view.RenderCacheRevision == renderView.RenderCacheRevision {
244		i.view.AdoptRenderCacheFrom(renderView)
245	}
246
247	i.stableChatCache = append(i.stableChatCache[:0], rows...)
248	i.stableChatCacheKey = key
249	i.stableChatCacheValid = true
250	return append([]string(nil), rows...)
251}
252func (i *Interactive) buildChatLocked(cols int) []string {
253	if i.agent != nil {
254		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
255		i.view.MessagesRevision = i.agent.Revision()
256	} else {
257		i.view.Messages = nil
258		i.view.MessagesRevision = 0
259	}
260	// Pacer flush: while the streaming pacer is still draining the
261	// buffer (i.e. EvAssistantMessage already fired but more runes
262	// are queued), the final assistant message is already in
263	// i.agent.Messages() in full. Painting it in the transcript
264	// AND the streaming block at the same time shows the user the
265	// complete text immediately — which defeats the whole pacer.
266	// Hide the last message until the pacer catches up; once the
267	// flush-pending latch clears, the message is revealed (the
268	// streaming block disappears the same frame because streamOn
269	// flips off, so the transition is seamless).
270	if i.streamFlushPending && len(i.view.Messages) > 0 {
271		i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
272	}
273	i.view.Streaming = i.streaming.String()
274	i.view.StreamingActive = i.streamOn
275	// Guard against the narrow race where EvAssistantMessage has
276	// just promoted a streaming reply into the transcript but a
277	// render tick hasn't flipped streamOn off yet. Without the
278	// guard, the same text would appear twice (once as the
279	// in-flight streaming block, once as the last transcript
280	// message). We detect the duplicate strictly: the last
281	// assistant message's visible text must equal the streaming
282	// buffer. Just matching on role is too broad — it also hides
283	// the next round's typewriter streaming after a tool turn,
284	// because the last transcript message is always assistant
285	// (the tool-use block) until the follow-up summary lands.
286	if i.streamOn && i.streaming.Len() > 0 {
287		if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
288			if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
289				i.view.StreamingActive = false
290			}
291		}
292	}
293	// Live tool-call view: only shown while a turn is in flight. Once
294	// the agent is idle, every tool call has already been folded into
295	// the transcript (as assistant.ToolCallBlock + a tool-role message),
296	// so showing v.ToolCalls a second time would duplicate them below
297	// the final assistant text — which looks like the summary came
298	// "before" the tools.
299	i.view.ToolCalls = i.view.ToolCalls[:0]
300	if i.busy {
301		for _, id := range i.toolOrder {
302			// Deterministic ordering: a tool block stays hidden until
303			// the paced assistant text that preceded it has finished
304			// typing out. toolOrder is append-only in arrival order,
305			// so once one tool is still gated, every later tool is too,
306			// so stop here to avoid showing a tool out of sequence.
307			if !i.toolGateOpenLocked(id) {
308				break
309			}
310			if tc, ok := i.toolCalls[id]; ok {
311				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
312			}
313		}
314	}
315	i.view.Err = i.statusErr
316	// Live streaming/tool rows are appended to the chat buffer (not
317	// hoisted into a separate live block above the editor). That keeps
318	// the renderer's diff view append-only: when a tool finishes the
319	// rows update in place at the end of the buffer, instead of the
320	// whole bottom band shrinking and shifting chat lines around.
321	i.liveBlock = nil
322	stable := i.stableChatRowsLocked(cols)
323	chat := append([]string(nil), stable...)
324	chat = append(chat, i.view.BuildLive(cols)...)
325
326	// Welcome banner: shown at the top of the chat area when there is
327	// no transcript yet. Disappears after the first message is sent.
328	// The version suffix is shown for welcomeVersionDuration after
329	// startup, then drops off automatically.
330	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
331		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
332		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
333	}
334
335	// Update-available banner: prepended above everything else so it's
336	// the first thing the user sees when opening a new zut session.
337	// Once rendered, it stays until the user updates to a newer
338	// version — we don't persist a "dismissed" flag because this is
339	// cheap and re-showing it is how most users remember to update.
340	if i.updateInfo.Available {
341		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
342		chat = append(banner, chat...)
343	}
344
345	// /help block: appended to the transcript so it appears at the
346	// bottom of the chat area (right above the status bar / editor).
347	// Prepending it would push long conversations off the top of the
348	// viewport, which users would miss entirely.
349	if len(i.helpBlock) > 0 {
350		chat = append(chat, i.helpBlock...)
351	}
352
353	if i.statusOK != "" {
354		// Hard-truncate the OK line to the visible width so a long
355		// session path ("resumed session: /Users/.../sessions/...")
356		// doesn't overflow past the right edge and look broken on a
357		// narrow terminal.
358		line := "✓ " + i.statusOK
359		if cols > 4 && len(line) > cols {
360			line = line[:cols-3] + "..."
361		}
362		chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
363	}
364
365	// Live shell-escape output (!command / entry.pre) streams into the
366	// transcript area while the command runs, then is replaced by the
367	// final user-context message when it finishes.
368	if i.shellRunning && i.shellLive != "" {
369		chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
370		chat = append(chat, "")
371	}
372
373	// Extension notes (notify / display) live just under the
374	// transcript, above the dialog/editor band. Cleared by /clear.
375	if len(i.extNotes) > 0 {
376		chat = append(chat, i.extNotes...)
377		chat = append(chat, "")
378	}
379
380	// Reload failures remain in the scrolling chat until /clear. They are
381	// host-only display state, not provider messages or persisted context.
382	// While the latest failure is still the temporary status error, hide its
383	// stored copy to avoid painting the same message twice.
384	reloadErrors := i.reloadErrors
385	if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
386		reloadErrors = reloadErrors[:n-1]
387	}
388	if len(reloadErrors) > 0 {
389		chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
390		chat = append(chat, "")
391	}
392
393	// Strip trailing blank rows so the chat content sits flush
394	// against the new "blank above status bar" row added by the
395	// bottom-region assembly. Build() ends every message with a
396	// blank separator; without this trim, the final message in
397	// the transcript would have its own trailing blank plus the
398	// status block's leading blank, doubling the gap.
399	for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
400		chat = chat[:len(chat)-1]
401	}
402	return chat
403}
404func (i *Interactive) sessionsRoot() string {
405	if i.cfg.SessionsRoot != "" {
406		return i.cfg.SessionsRoot
407	}
408	return i.cfg.ZutHome
409}
410func (i *Interactive) lastCols() int {
411	cols, _ := i.cfg.Terminal.Size()
412	return cols
413}
414func (i *Interactive) chatPage() int {
415	_, rows := i.cfg.Terminal.Size()
416	p := rows - 6 // rough reservation for status + editor + a dialog line
417	if p < 4 {
418		p = 4
419	}
420	return p
421}
422func (i *Interactive) scrollBy(delta int) {
423	i.mu.Lock()
424	i.scrollOffset += delta
425	if i.scrollOffset < 0 {
426		i.scrollOffset = 0
427	}
428	if i.scrollOffset == 0 {
429		i.parkedTurn = 0
430		i.parkedTotal = 0
431	}
432	if i.rend != nil {
433		// VS Code's terminal is especially prone to leaving stray
434		// wrapped-character fragments behind during scroll-driven
435		// viewport changes. Force a full repaint on scroll, but
436		// avoid a whole-screen clear because that visibly flickers.
437		i.requestRendererInvalidate()
438	}
439	i.mu.Unlock()
440	i.invalidate()
441}
442func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
443	adj := (newLen - prevLen) - (newRows - prevRows)
444	offset += adj
445	if offset < 0 {
446		offset = 0
447	}
448	if offset > newLen {
449		offset = newLen
450	}
451	return offset
452}
453func (i *Interactive) scrollToBottom() {
454	i.mu.Lock()
455	i.scrollOffset = 0
456	i.parkedTurn = 0
457	i.parkedTotal = 0
458	// Reset the auto-follow baseline. scrollToBottom is the resume /
459	// session-swap snap point, where the chat buffer changes length
460	// wholesale. Without zeroing these, the next render's follow guard
461	// compares the fresh transcript's length against a stale baseline
462	// and nudges scrollOffset, which reads as a viewport jump right
463	// after resume. See commit 43da5e5 for the same fix on new turns.
464	i.prevChatLen = 0
465	i.prevChatCols = 0
466	i.requestRendererInvalidate()
467	i.mu.Unlock()
468	i.invalidate()
469}
470func (i *Interactive) redraw() {
471	i.mu.Lock()
472	renderRevision := i.renderRevision.Load()
473
474	cols, rows := i.cfg.Terminal.Size()
475	mainCols := cols
476	rightBarWidgets := i.rightBarWidgetsLocked()
477	rightBarActive := false
478	var rightBarWidth int
479	if !i.rightBarHidden && len(rightBarWidgets) > 0 {
480		if width, rail, ok := tui.RightBarColumns(cols); ok {
481			mainCols = width
482			rightBarWidth = rail
483			rightBarActive = true
484		}
485	}
486	rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
487	i.renderOutsideLock = true
488	chat := i.cachedChatLocked(mainCols)
489	i.renderOutsideLock = false
490	if i.renderRevision.Load() != renderRevision {
491		i.mu.Unlock()
492		return
493	}
494
495	// Independent views render into a floating pane over the live transcript.
496	// The pane owns its geometry and viewport; each view retains its own local
497	// selection, editor, and asynchronous state.
498	paneMax := tui.FloatingPaneMaxRect(cols, rows)
499	dialogWidth := paneMax.ContentWidth()
500	var dialog []string
501	var dialogID string
502	switch {
503	case i.dialog.Active():
504		dialogID = "login"
505		dialog = i.dialog.Render(i.cfg.Theme, dialogWidth)
506	case i.modelDialog.Active():
507		dialogID = "model"
508		dialog = i.modelDialog.Render(i.cfg.Theme, dialogWidth)
509	case i.llamaDialog.Active():
510		dialogID = "llama"
511		dialog = i.llamaDialog.Render(i.cfg.Theme, dialogWidth)
512	case i.rescueDialog.Active():
513		dialogID = "rescue"
514		dialog = i.rescueDialog.Render(i.cfg.Theme, dialogWidth)
515	case i.sessionDialog.Active():
516		dialogID = "sessions"
517		i.sessionDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
518		dialog = i.sessionDialog.Render(i.cfg.Theme, dialogWidth)
519	case i.residentSubagentsDialog.Active():
520		dialogID = "subagents"
521		dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, dialogWidth, paneMax.ContentHeight())
522	case i.residentChildSession != nil:
523		dialogID = "subagent-session"
524		dialog = i.residentChildSession.Render(dialogWidth, paneMax.ContentHeight())
525	case i.jumpDialog.Active():
526		dialogID = "jump"
527		dialog = i.jumpDialog.Render(i.cfg.Theme, dialogWidth)
528	case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
529		dialogID = "extension-panel"
530		dialog = i.extPanel.Render(i.cfg.Theme, dialogWidth)
531	case i.confirmDialog.Active():
532		dialogID = "confirmation"
533		if i.btwDialog.Active() {
534			dialog = renderBtwConfirmation(i.cfg.Theme, dialogWidth, i.btwDialog, i.confirmDialog)
535		} else {
536			dialog = i.confirmDialog.Render(i.cfg.Theme, dialogWidth)
537		}
538	case i.btwDialog.Active():
539		dialogID = "side-chat"
540		dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth)
541	case i.skillsDialog.Active():
542		dialogID = "skills"
543		if i.skillsDialog.Viewing() {
544			dialogID = "skills-body"
545		}
546		dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth)
547	case i.changelogDialog.Active():
548		dialogID = "changelog"
549		dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth)
550	case i.logoutDialog.Active():
551		dialogID = "logout"
552		dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth)
553	case i.telegramDialog.Active():
554		dialogID = "telegram"
555		dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth)
556	case i.settingsDialog.Active():
557		dialogID = "settings"
558		dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth)
559	case i.sessionOpsDialog.Active():
560		dialogID = "session-ops"
561		dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth)
562	case i.sessionTreeDialog.Active():
563		dialogID = "session-tree"
564		i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
565		dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
566	}
567	dialogTitle := floatingOverlayTitle(dialogID)
568	dialogRemovedTopRows := 0
569	if len(dialog) > 0 {
570		dialog = padDialogFrame(dialog)
571		frameTitle, body, removedTopRows := floatingDialogBody(dialog)
572		if frameTitle != "" {
573			dialogTitle = frameTitle
574		}
575		dialog, dialogRemovedTopRows = body, removedTopRows
576	}
577	dialogFocusRow := -1
578	for row, line := range dialog {
579		if strings.Contains(line, i.cfg.Theme.SelectionStyle()) {
580			dialogFocusRow = row
581			break
582		}
583	}
584	modalBackdrop := len(dialog) > 0
585
586	// Slash-command autocomplete: popup above the status line, only
587	// when the editor starts with "/" and no dialog is already open.
588	// Feed extension-registered commands into the suggester first so
589	// they show up in tab-complete + the popup alongside the built-ins.
590	i.suggest.SetJailed(i.cfg.Sandbox.Locked())
591	i.suggest.SetLlamaConfigured(i.llamaConfigured)
592	if i.cfg.Extensions != nil {
593		catalog := i.cfg.Extensions.Commands()
594		extra := make([]slashCommand, 0, len(catalog))
595		for _, c := range catalog {
596			// The popup renders extension commands under a dedicated
597			// "── extensions ───" divider, so the description doesn't
598			// need to repeat the source. If the description is empty,
599			// fall back to the extension name so the row isn't blank.
600			desc := c.Description
601			if strings.TrimSpace(desc) == "" {
602				desc = "(" + c.Extension + ")"
603			}
604			extra = append(extra, slashCommand{
605				Name: "/" + c.Name,
606				Desc: desc,
607			})
608		}
609		i.suggest.SetExtra(extra)
610	}
611	var suggest []string
612	currentInput := i.ed.Value()
613	if i.suggest.SkillInputStarted(currentInput) {
614		var list []*skills.Skill
615		if i.cfg.SkillSnapshot != nil {
616			list = i.cfg.SkillSnapshot()
617		}
618		i.suggest.SetSkills(list)
619	}
620	// Slash popup renders even while the agent is busy so the user
621	// can queue a destructive command (/clear, /compact, /logout,
622	// /model) or a read-only one (/help, /jump, /sessions, etc.)
623	// without waiting for the current turn to finish. The dispatcher
624	// in runSlash already handles the busy case per-command: safe
625	// ones run immediately, destructive ones cancel the turn first.
626	i.fileSuggest.SetCWD(i.cfg.CWD)
627	mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
628	if mainInputFocused && i.suggest.Active(currentInput) {
629		suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
630	} else if mainInputFocused && i.fileSuggest.Active(currentInput) {
631		suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
632	}
633
634	// Detect overlay close (any dialog or slash/file suggestion popup
635	// just transitioned from open to closed). Force a full redraw so
636	// the rows the overlay occupied are guaranteed to be repainted
637	// from the chat below, instead of the diff path leaving stale
638	// dialog content behind. Equivalent to the user pressing ctrl+l.
639	overlayOpen := len(dialog) > 0 || len(suggest) > 0
640	if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
641		// An overlay (dialog or slash/file popup) just closed, so the
642		// bottom band shrinks. On terminals where we can drop
643		// scrollback, a full Clear is the simplest way to guarantee
644		// the vacated rows are repainted from the chat below.
645		//
646		// On VS Code's terminal closing a dialog leaves the stale
647		// overlay rows in the retained scrollback (we can't drop them
648		// with the quiet in-place diff). Run the same full Clear() that
649		// Ctrl+L uses so the scrollback is purged and the conversation
650		// is repainted clean, matching what the user expects after
651		// dismissing a picker. Clear() is keepScrollback-aware and
652		// emits \x1b[3J there.
653		i.rend.Clear()
654	}
655	i.prevOverlayOpen = overlayOpen
656	if len(suggest) > 0 {
657		// One blank row above the popup so it doesn't sit flush
658		// against the chat / welcome content above.
659		suggest = append([]string{""}, suggest...)
660	}
661
662	// Busy prefix shown at the far left of the status bar. The spinner owns
663	// only its frame; the activity label reports the operation in progress.
664	var busyPrefix string
665	if i.busy {
666		label := i.activity.label()
667		if label == "" {
668			label = "Preparing request"
669		}
670		busyPrefix = fmt.Sprintf("%s %s %s %s",
671			i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
672			i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
673			i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
674			i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
675		)
676	}
677
678	ctxMax := 0
679	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
680		ctxMax = m.ContextWindow
681	}
682	statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
683	workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
684	workingWithStatus := statusPosition == workingPosition
685	statusBusyPrefix := ""
686	if workingWithStatus {
687		statusBusyPrefix = busyPrefix
688	}
689	goalStatus := string(i.goalStatus)
690	statusLines := tui.StatusBar(tui.StatusBarParams{
691		Theme:          i.cfg.Theme,
692		Provider:       i.cfg.Provider,
693		Model:          i.cfg.Model,
694		Reasoning:      i.cfg.Reasoning,
695		FastMode:       i.cfg.FastMode != nil && *i.cfg.FastMode,
696		Busy:           i.busy,
697		BusyPrefix:     statusBusyPrefix,
698		CWD:            i.cfg.CWD,
699		Locked:         i.cfg.Sandbox.Locked(),
700		NoYolo:         i.cfg.NoYolo,
701		GoalStatus:     goalStatus,
702		Usage:          i.cumUsage,
703		Subscription:   i.cfg.AuthMethod == "oauth",
704		ContextUsed:    i.lastCtxInput,
705		ContextMax:     ctxMax,
706		AutoCompacting: i.autoCompacting,
707		Telegram:       i.telegramBridge != nil && i.telegramBridge.Active(),
708		Cols:           mainCols,
709	})
710	inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
711	if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
712		i.ed.Prompt = ""
713	} else {
714		i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
715	}
716	edLines, curR, curC := i.ed.Render(mainCols)
717	// Floating independent views no longer replace the main bottom band: the
718	// underlying chat, status, and editor keep updating behind the pane.
719	residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
720	var allResidentSubagentLines []string
721	residentSubagentHidden := 0
722	if !residentViewActive && i.cfg.ResidentManager != nil {
723		const residentIndicatorSnapshotLimit = 8
724		snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
725		allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
726		residentSubagentHidden = total - len(snapshots)
727	}
728	var workingLines []string
729	if busyPrefix != "" && !workingWithStatus {
730		workingLines = []string{"  " + busyPrefix}
731	}
732	inputCursorOffset := 0
733	inputCursorColOffset := 0
734	switch inputStyle {
735	case tui.InputStyleLines:
736		edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
737		inputCursorOffset = 1
738	case tui.InputStyleBlock:
739		edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
740		inputCursorOffset = 1
741		inputCursorColOffset = 2
742	}
743
744	// "Sliding in" chips for messages the user typed while a turn is
745	// in flight. Shown directly above the status bar so they're close
746	// to the editor but don't push the chat around.
747	var queue []string
748	queued := append([]string(nil), i.queued...)
749	if i.agent != nil {
750		queued = append(queued, i.agent.PendingQueuedMessages()...)
751	}
752	if len(queued) > 0 {
753		queue = append(queue, "")
754		for _, q := range queued {
755			label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "  sliding in: ")
756			text := truncateLine(q, mainCols-17)
757			queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
758		}
759		// Hint row, rendered in the same muted tone as the model
760		// info on the status bar so it reads as ambient metadata
761		// rather than a chip. Tells the user how to recover the
762		// most recent queued message back into the editor.
763		hint := "  Press " + slideBackChordHint() + " to slide back into input"
764		queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
765	}
766
767	extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
768
769	// Bottom-sticky sections (always visible, never scroll). Each
770	// non-empty subsection (dialog, suggest popup, sliding-in queue)
771	// is preceded by one blank row so it has air above the chat
772	// content. The status block and editor get their own dedicated
773	// blanks so spacing stays consistent whether or not a dialog or
774	// popup is showing.
775	composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
776		bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
777		bottom = append(bottom, suggest...)
778		bottom = append(bottom, queue...)
779		lineInput := inputStyle == tui.InputStyleLines
780		statusBelow := statusPosition == tui.StatusPositionBelowInput
781		workingBelow := workingPosition == tui.WorkingPositionBelowInput
782		var aboveInput []string
783		aboveInput = append(aboveInput, extensionLines...)
784		if !statusBelow {
785			aboveInput = append(aboveInput, statusLines...)
786		}
787		if !workingBelow {
788			aboveInput = append(aboveInput, workingLines...)
789		}
790		var belowInput []string
791		// Background resident work belongs directly below the editor. It is
792		// deliberately independent of the main turn's busy/status placement.
793		belowInput = append(belowInput, residentSubagentLines...)
794		if workingBelow {
795			belowInput = append(belowInput, workingLines...)
796		}
797		if statusBelow {
798			belowInput = append(belowInput, statusLines...)
799		}
800
801		bottom = append(bottom, "")
802		bottom = append(bottom, aboveInput...)
803		needsGapAboveInput := len(aboveInput) > 0 && !lineInput
804		if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
805			needsGapAboveInput = true
806		}
807		if needsGapAboveInput {
808			bottom = append(bottom, "")
809		}
810		inputStartRow = len(bottom)
811		bottom = append(bottom, edLines...)
812		if len(belowInput) > 0 {
813			// Active resident work sits immediately below the prompt. Other
814			// below-input chrome keeps the usual breathing room.
815			if !lineInput && len(residentSubagentLines) == 0 {
816				bottom = append(bottom, "")
817			}
818			bottom = append(bottom, belowInput...)
819		}
820		return bottom, inputStartRow
821	}
822
823	const rendererBottomMarginRows = 1
824	maxBottomRows := rows - rendererBottomMarginRows - 1
825	var residentSubagentLines []string
826	var bottom []string
827	var inputStartRow int
828	if len(allResidentSubagentLines) > 0 {
829		for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
830			candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
831			candidateBottom, _ := composeBottom(candidate)
832			if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
833				residentSubagentLines = candidate
834				break
835			}
836		}
837	}
838	bottom, inputStartRow = composeBottom(residentSubagentLines)
839
840	chatRows := rows - len(bottom)
841	if chatRows < 1 {
842		chatRows = 1
843	}
844
845	// Auto-follow guard: when the user has scrolled up (scrollOffset
846	// > 0) and the agent appends new content below the viewport while
847	// they're reading, compensate so the visible content stays
848	// anchored. scrollOffset is measured from the bottom of `chat`,
849	// so without compensation a growing buffer pushes the window
850	// downward through the content and the lines the user was
851	// reading scroll off the top.
852	//
853	// Skip compensation when the terminal width changed (a resize
854	// reflows the whole buffer and the line-count delta no longer
855	// corresponds to appended content) and when scrollOffset is 0
856	// (the user is following the tail and wants new content to push
857	// the view down as usual).
858	//
859	// The window the user sees starts at row
860	//   start = len(chat) - scrollOffset - chatRows
861	// so to keep `start` fixed across a redraw we must offset by both
862	// the buffer growth (len delta) AND the viewport-height change
863	// (chatRows delta, e.g. the status band or sliding-in queue
864	// appearing during a turn). Compensating only for the len delta
865	// let a shrinking chatRows pull the window toward the tail, which
866	// read as the viewport jumping to the bottom whenever the agent
867	// streamed text or a tool call grew the bottom band.
868	if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
869		i.scrollOffset = anchorScrollOffset(i.scrollOffset,
870			i.prevChatLen, len(chat), i.prevChatRows, chatRows)
871	}
872	i.prevChatLen = len(chat)
873	i.prevChatCols = mainCols
874	i.prevChatRows = chatRows
875
876	// Apply scroll offset to the chat slice.
877	maxOffset := len(chat) - chatRows
878	if maxOffset < 0 {
879		maxOffset = 0
880	}
881	// Tail-render expansion: if the user has scrolled to (or above)
882	// the top of the currently rendered tail and there are still
883	// truncated messages above, widen view.TailLimit and rebuild.
884	// The chat cache is keyed on tailLimit so the next cachedChatLocked
885	// will re-issue Build instead of returning the stale slice. We
886	// rebuild immediately so the same redraw shows the freshly-revealed
887	// rows; otherwise the user would have to scroll again to see them.
888	if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
889		prevLen := len(chat)
890		i.view.TailLimit += resumeTailExpandStep
891		if i.view.TailLimit >= len(i.view.Messages) {
892			i.view.TailLimit = 0 // unbounded
893		}
894		i.chatCacheValid = false
895		chat = i.cachedChatLocked(mainCols)
896		for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
897			chat = chat[:len(chat)-1]
898		}
899		// Newly-revealed rows are older messages prepended at the top.
900		// scrollOffset counts rows from the bottom, so to keep the
901		// viewport visually anchored on the same content the user was
902		// looking at we shift it up by the number of rows added. Keep
903		// the auto-follow baseline (prevChatLen) in sync with the
904		// post-expansion length too, so the next render's follow guard
905		// doesn't see this growth as a synthetic delta and yank the
906		// viewport again.
907		if grew := len(chat) - prevLen; grew > 0 {
908			i.scrollOffset += grew
909		}
910		i.prevChatLen = len(chat)
911		i.prevChatCols = mainCols
912		maxOffset = len(chat) - chatRows
913		if maxOffset < 0 {
914			maxOffset = 0
915		}
916	}
917	if i.scrollOffset > maxOffset {
918		i.scrollOffset = maxOffset
919	}
920	if i.scrollOffset < 0 {
921		i.scrollOffset = 0
922	}
923
924	var visibleChat []string
925	if len(chat) <= chatRows {
926		visibleChat = chat
927	} else {
928		end := len(chat) - i.scrollOffset
929		rawStart := end - chatRows
930		if rawStart < 0 {
931			rawStart = 0
932		}
933		start := snapViewportStartToImageBlock(chat, rawStart)
934		// If the snap pulled start upward (an image-block was atomic) while
935		// the user is scrolling downward, the viewport would sit on the same
936		// image until the user mashes down past every reserved row. Bump
937		// scrollOffset past the image so one keypress always clears it.
938		if start < rawStart && i.scrollOffset < i.prevScrollOffset {
939			jump := rawStart - start
940			i.scrollOffset -= jump
941			if i.scrollOffset < 0 {
942				i.scrollOffset = 0
943			}
944			end = len(chat) - i.scrollOffset
945			rawStart = end - chatRows
946			if rawStart < 0 {
947				rawStart = 0
948			}
949			start = snapViewportStartToImageBlock(chat, rawStart)
950		}
951		end = start + chatRows
952		if end > len(chat) {
953			end = len(chat)
954			start = end - chatRows
955			if start < 0 {
956				start = 0
957			}
958		}
959		visibleChat = chat[start:end]
960	}
961	i.prevScrollOffset = i.scrollOffset
962	visibleChat = clipBottomClippedImages(visibleChat)
963
964	// A tiny "scrolled up" indicator in the top-right of the chat pane
965	// so you know you're not at the bottom. When the viewport was
966	// parked by /jump we include the turn number so the user remembers
967	// they're reading history rather than the live conversation.
968	if i.scrollOffset > 0 && len(visibleChat) > 0 {
969		var text string
970		if i.parkedTurn > 0 && i.parkedTotal > 0 {
971			text = fmt.Sprintf("  ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
972				i.parkedTurn, i.parkedTotal, i.scrollOffset)
973		} else {
974			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
975		}
976		note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
977		visibleChat = append([]string{note}, visibleChat...)
978		if len(visibleChat) > chatRows {
979			visibleChat = visibleChat[:chatRows]
980		}
981	}
982
983	// Default: the real terminal cursor sits on the main editor's
984	// input position. In main-screen log mode cursor rows are relative
985	// to the fixed bottom band, not the chat transcript.
986	// Floating content has its own coordinate space, so dialog cursor rows
987	// remain relative to the content passed to FloatingPane.Compose.
988	dialogLead := 0
989	cursorRow := inputStartRow + inputCursorOffset + curR
990	cursorCol := curC + inputCursorColOffset
991	cursorInDialog := false
992	if i.btwDialog.Active() {
993		if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 {
994			cursorRow = dialogLead + r
995			cursorCol = c
996			cursorInDialog = true
997		}
998	}
999	if i.dialog.Active() {
1000		if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 {
1001			cursorRow = dialogLead + r
1002			cursorCol = c
1003			cursorInDialog = true
1004		}
1005	}
1006	if i.llamaDialog.Active() {
1007		if r, c := i.llamaDialog.CursorPos(); r >= 0 {
1008			cursorRow = dialogLead + r
1009			cursorCol = c
1010			cursorInDialog = true
1011		} else {
1012			cursorRow = -1
1013			cursorCol = 0
1014		}
1015	}
1016	if i.sessionDialog.Active() {
1017		if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1018			cursorRow = dialogLead + r
1019			cursorCol = c
1020			cursorInDialog = true
1021		}
1022	}
1023	if i.sessionTreeDialog.Active() {
1024		if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1025			cursorRow = dialogLead + r
1026			cursorCol = c
1027			cursorInDialog = true
1028		}
1029	}
1030	if i.extPanel.Active() {
1031		cursorRow = -1
1032		cursorCol = 0
1033	}
1034	dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
1035	if cursorInDialog {
1036		dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol
1037		if dialogCursorRow < 0 {
1038			dialogCursorRow = -1
1039		}
1040	}
1041	i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
1042	if i.renderRevision.Load() != renderRevision {
1043		i.mu.Unlock()
1044		return
1045	}
1046	theme := i.cfg.Theme
1047	// State assembly above is synchronized; terminal output is owned by
1048	// this renderer goroutine and must not hold the global state mutex.
1049	i.mu.Unlock()
1050	if modalBackdrop {
1051		background := floatingBackgroundFrame(visibleChat, bottom, rows)
1052		if rightBarActive {
1053			rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1054			background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth)
1055		}
1056		pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
1057		i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)
1058	} else if rightBarActive {
1059		rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1060		i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
1061	} else {
1062		_ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1063		i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1064	}
1065	i.mu.Lock()
1066	if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1067		alert := *i.pendingAlert
1068		i.pendingAlert = nil
1069		i.emitAlertLocked(alert)
1070	}
1071	i.mu.Unlock()
1072}
1073
1074// floatingOverlayTitle supplies every overlay with a stable, human-readable
1075// title even when its body does not use the legacy frame-header convention.
1076func floatingOverlayTitle(id string) string {
1077	return strings.ReplaceAll(id, "-", " ")
1078}
1079
1080// floatingDialogBody removes a legacy dialog's rule chrome and returns its
1081// title for the shared floating-pane border.
1082func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
1083	if len(lines) == 0 {
1084		return "", nil, 0
1085	}
1086	body = lines
1087	if isFrameHeaderLine(body[0]) {
1088		plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ")
1089		title = strings.TrimSpace(strings.TrimRight(plain, "─"))
1090		body = body[1:]
1091		removedTopRows = 1
1092	}
1093	if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) {
1094		body = body[:last]
1095	}
1096	return title, body, removedTopRows
1097}
1098
1099// floatingBackgroundFrame preserves the regular viewport layout while an
1100// independent view is open. It is a fixed terminal frame only for the overlay
1101// lifetime; normal rendering immediately returns to scrollback-oriented flow
1102// when the pane closes.
1103func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
1104	if rows < 1 {
1105		return nil
1106	}
1107	// Match DrawLog's logical order and renderer-owned bottom margin, then
1108	// retain exactly the terminal viewport. In particular, an editor/status
1109	// band taller than the terminal must show its newest rows, not its oldest.
1110	lines := make([]string, 0, len(chat)+len(bottom)+1)
1111	lines = append(lines, chat...)
1112	lines = append(lines, bottom...)
1113	lines = append(lines, "")
1114	if len(lines) > rows {
1115		lines = lines[len(lines)-rows:]
1116	}
1117	frame := make([]string, rows)
1118	// DrawLog's flow renderer leaves the logical session content at the
1119	// viewport's top. Keep that anchoring when switching to a fixed floating
1120	// frame so opening a pane does not visibly jump the whole session down.
1121	copy(frame, lines)
1122	return frame
1123}
1124
1125func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string {
1126	for row := range main {
1127		barLine := ""
1128		if row < len(rightBar) {
1129			barLine = rightBar[row]
1130		}
1131		main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth)
1132	}
1133	return main
1134}
1135
1136func hasImageEscape(line string) bool {
1137	return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1138}
1139func snapViewportStartToImageBlock(chat []string, start int) int {
1140	if start <= 0 || start >= len(chat) {
1141		return start
1142	}
1143	if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1144		return start
1145	}
1146	for k := start - 1; k >= 0; k-- {
1147		line := chat[k]
1148		if hasImageEscape(line) {
1149			return k
1150		}
1151		if !isBoxBlankLine(line) {
1152			break
1153		}
1154	}
1155	return start
1156}
1157func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1158	if len(msgs) == 0 {
1159		return nil
1160	}
1161	out := make([]provider.Message, 0, len(msgs))
1162	for _, m := range msgs {
1163		if isHiddenTranscriptMessage(m) {
1164			continue
1165		}
1166		out = append(out, m)
1167	}
1168	return out
1169}
1170func isHiddenTranscriptMessage(m provider.Message) bool {
1171	if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
1172		return true
1173	}
1174	if m.Role != provider.RoleUser || len(m.Content) == 0 {
1175		return false
1176	}
1177	tb, ok := m.Content[0].(provider.TextBlock)
1178	if !ok {
1179		return false
1180	}
1181	return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1182}
1183func clipBottomClippedImages(lines []string) []string {
1184	if len(lines) == 0 {
1185		return lines
1186	}
1187	out := append([]string(nil), lines...)
1188	for i, line := range out {
1189		if !hasImageEscape(line) {
1190			continue
1191		}
1192		// Image blocks render as: image escape, zero or more blank
1193		// reservation rows, then the muted "image - ..." info line,
1194		// then one trailing blank. If the info line isn't visible in
1195		// the current chat slice, the image would paint down into the
1196		// fixed status bar area. Suppress that image for this frame.
1197		//
1198		// When the image lives inside a tool box, the reservation rows
1199		// are wrapped in vertical box edges ("│  ...  │"); those rows
1200		// look non-blank under a naive whitespace check but are still
1201		// reservation rows for this scan, so treat them as blank.
1202		foundInfo := false
1203		for j := i + 1; j < len(out); j++ {
1204			if strings.Contains(out[j], "image - ") {
1205				foundInfo = true
1206				break
1207			}
1208			if !isBoxBlankLine(out[j]) {
1209				break
1210			}
1211		}
1212		if !foundInfo {
1213			out[i] = ""
1214		}
1215	}
1216	return out
1217}
1218func isBoxBlankLine(line string) bool {
1219	stripped := stripANSIBytes(line)
1220	stripped = strings.TrimSpace(stripped)
1221	stripped = strings.Trim(stripped, "│")
1222	stripped = strings.TrimSpace(stripped)
1223	return stripped == ""
1224}
1225func stripANSIBytes(s string) string {
1226	if !strings.Contains(s, "\x1b") {
1227		return s
1228	}
1229	var b strings.Builder
1230	b.Grow(len(s))
1231	i := 0
1232	for i < len(s) {
1233		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1234			end := i + 2
1235			for end < len(s) {
1236				c := s[end]
1237				end++
1238				if c >= 0x40 && c <= 0x7e {
1239					break
1240				}
1241			}
1242			i = end
1243			continue
1244		}
1245		b.WriteByte(s[i])
1246		i++
1247	}
1248	return b.String()
1249}
1250func panelKeyName(k tui.Key) string {
1251	switch k.Kind {
1252	case tui.KeyUp:
1253		return "up"
1254	case tui.KeyDown:
1255		return "down"
1256	case tui.KeyLeft:
1257		return "left"
1258	case tui.KeyRight:
1259		return "right"
1260	case tui.KeyEnter:
1261		return "enter"
1262	case tui.KeyEsc:
1263		return "esc"
1264	case tui.KeyTab:
1265		return "tab"
1266	case tui.KeyBackspace:
1267		return "backspace"
1268	case tui.KeyDelete:
1269		return "delete"
1270	case tui.KeyHome:
1271		return "home"
1272	case tui.KeyEnd:
1273		return "end"
1274	case tui.KeyPageUp:
1275		return "pageup"
1276	case tui.KeyPageDown:
1277		return "pagedown"
1278	case tui.KeyRune:
1279		return "rune"
1280	default:
1281		return "unknown"
1282	}
1283}
1284func panelKeyText(k tui.Key) string {
1285	if k.Kind == tui.KeyRune {
1286		return string(k.Rune)
1287	}
1288	return ""
1289}
1290func truncateLine(s string, n int) string {
1291	if n <= 0 {
1292		return ""
1293	}
1294	// Collapse newlines — chips are single line.
1295	s = strings.ReplaceAll(s, "\n", " ↩ ")
1296	runes := []rune(s)
1297	if len(runes) <= n {
1298		return s
1299	}
1300	if n <= 3 {
1301		return strings.Repeat(".", n)
1302	}
1303	return string(runes[:n-3]) + "..."
1304}

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