v0.25.1 025a25b

interactive_rendering.go RawHistoryFolder

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

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