v0.51.0 a9d674e

interactive_rendering.go RawHistoryFolder

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

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