v0.29.1 d92a60f

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

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