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 ModelProfile: i.cfg.ActiveModelProfile,
677 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
678 Busy: i.busy,
679 BusyPrefix: statusBusyPrefix,
680 CWD: i.cfg.CWD,
681 Locked: i.cfg.Sandbox.Locked(),
682 NoYolo: i.cfg.NoYolo,
683 GoalStatus: goalStatus,
684 PlanCurrent: i.planCurrent,
685 PlanTotal: i.planTotal,
686 Usage: i.cumUsage,
687 Subscription: i.cfg.AuthMethod == "oauth",
688 WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()),
689 ContextUsed: i.lastCtxInput,
690 ContextMax: ctxMax,
691 AutoCompacting: i.autoCompacting,
692 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
693 Cols: mainCols,
694 })
695 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
696 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
697 i.ed.Prompt = ""
698 } else {
699 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
700 }
701 edLines, curR, curC := i.ed.Render(mainCols)
702 // Floating independent views no longer replace the main bottom band: the
703 // underlying chat, status, and editor keep updating behind the pane.
704 residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
705 var workingLines []string
706 if busyPrefix != "" && !workingWithStatus {
707 workingLines = []string{" " + busyPrefix}
708 }
709 inputCursorOffset := 0
710 inputCursorColOffset := 0
711 switch inputStyle {
712 case tui.InputStyleLines:
713 edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
714 inputCursorOffset = 1
715 case tui.InputStyleBlock:
716 edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
717 inputCursorOffset = 1
718 inputCursorColOffset = 2
719 }
720
721 // "Sliding in" chips for messages the user typed while a turn is
722 // in flight. Shown directly above the status bar so they're close
723 // to the editor but don't push the chat around.
724 var queue []string
725 queued := append([]core.QueuedMessage(nil), i.queued...)
726 if i.agent != nil {
727 queued = append(queued, i.agent.PendingQueuedMessages()...)
728 }
729 if len(queued) > 0 {
730 queue = append(queue, "")
731 for _, q := range queued {
732 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
733 text := queuedMessageSummary(q, mainCols-17)
734 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
735 }
736 // Hint row, rendered in the same muted tone as the model
737 // info on the status bar so it reads as ambient metadata
738 // rather than a chip. Tells the user how to recover the
739 // most recent queued message back into the editor.
740 hint := " Press " + slideBackChordHint() + " to slide back into input"
741 queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
742 }
743
744 extensionLines := i.extensionChromeLinesLocked(mainCols)
745
746 // Bottom-sticky sections (always visible, never scroll). Each
747 // non-empty subsection (dialog, suggest popup, sliding-in queue)
748 // is preceded by one blank row so it has air above the chat
749 // content. The status block and editor get their own dedicated
750 // blanks so spacing stays consistent whether or not a dialog or
751 // popup is showing.
752 composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
753 bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
754 bottom = append(bottom, suggest...)
755 bottom = append(bottom, queue...)
756 lineInput := inputStyle == tui.InputStyleLines
757 statusBelow := statusPosition == tui.StatusPositionBelowInput
758 workingBelow := workingPosition == tui.WorkingPositionBelowInput
759 var aboveInput []string
760 aboveInput = append(aboveInput, extensionLines...)
761 if !statusBelow {
762 aboveInput = append(aboveInput, statusLines...)
763 }
764 if !workingBelow {
765 aboveInput = append(aboveInput, workingLines...)
766 }
767 var belowInput []string
768 // Background resident work belongs directly below the editor. It is
769 // deliberately independent of the main turn's busy/status placement.
770 belowInput = append(belowInput, residentSubagentLines...)
771 if workingBelow {
772 belowInput = append(belowInput, workingLines...)
773 }
774 if statusBelow {
775 belowInput = append(belowInput, statusLines...)
776 }
777
778 bottom = append(bottom, "")
779 bottom = append(bottom, aboveInput...)
780 needsGapAboveInput := len(aboveInput) > 0 && !lineInput
781 if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
782 needsGapAboveInput = true
783 }
784 if needsGapAboveInput {
785 bottom = append(bottom, "")
786 }
787 inputStartRow = len(bottom)
788 bottom = append(bottom, edLines...)
789 if len(belowInput) > 0 {
790 // Active resident work sits immediately below the prompt. Other
791 // below-input chrome keeps the usual breathing room.
792 if !lineInput && len(residentSubagentLines) == 0 {
793 bottom = append(bottom, "")
794 }
795 bottom = append(bottom, belowInput...)
796 }
797 return bottom, inputStartRow
798 }
799
800 const rendererBottomMarginRows = 1
801 maxBottomRows := rows - rendererBottomMarginRows - 1
802 var residentSubagentLines []string
803 var bottom []string
804 var inputStartRow int
805 if !residentViewActive && i.cfg.ResidentManager != nil {
806 residentCapacity := 0
807 for candidateRows := maxBottomRows; candidateRows > 0; candidateRows-- {
808 candidateBottom, _ := composeBottom(make([]string, candidateRows))
809 if len(candidateBottom) <= maxBottomRows {
810 residentCapacity = candidateRows
811 break
812 }
813 }
814 if residentCapacity > 0 {
815 residentLines, hiddenResidents := renderResidentSubagentActivityPage(i.cfg.Theme, i.cfg.ResidentManager, i.spin.FrameAt(i.clock()), residentCapacity, mainCols, i.clock())
816 if len(residentLines) > 0 || hiddenResidents > 0 {
817 residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, residentLines, hiddenResidents, maxBottomRows, mainCols, func(candidate []string) int {
818 candidateBottom, _ := composeBottom(candidate)
819 return len(candidateBottom)
820 })
821 }
822 }
823 }
824 bottom, inputStartRow = composeBottom(residentSubagentLines)
825
826 chatRows := rows - len(bottom)
827 if chatRows < 1 {
828 chatRows = 1
829 }
830
831 // Auto-follow guard: when the user has scrolled up (scrollOffset
832 // > 0) and the agent appends new content below the viewport while
833 // they're reading, compensate so the visible content stays
834 // anchored. scrollOffset is measured from the bottom of `chat`,
835 // so without compensation a growing buffer pushes the window
836 // downward through the content and the lines the user was
837 // reading scroll off the top.
838 //
839 // Skip compensation when the terminal width changed (a resize
840 // reflows the whole buffer and the line-count delta no longer
841 // corresponds to appended content) and when scrollOffset is 0
842 // (the user is following the tail and wants new content to push
843 // the view down as usual).
844 //
845 // The window the user sees starts at row
846 // start = len(chat) - scrollOffset - chatRows
847 // so to keep `start` fixed across a redraw we must offset by both
848 // the buffer growth (len delta) AND the viewport-height change
849 // (chatRows delta, e.g. the status band or sliding-in queue
850 // appearing during a turn). Compensating only for the len delta
851 // let a shrinking chatRows pull the window toward the tail, which
852 // read as the viewport jumping to the bottom whenever the agent
853 // streamed text or a tool call grew the bottom band.
854 if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
855 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
856 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
857 }
858 i.prevChatLen = len(chat)
859 i.prevChatCols = mainCols
860 i.prevChatRows = chatRows
861
862 // Apply scroll offset to the chat slice.
863 maxOffset := len(chat) - chatRows
864 if maxOffset < 0 {
865 maxOffset = 0
866 }
867 // Tail-render expansion: if the user has scrolled to (or above)
868 // the top of the currently rendered tail and there are still
869 // truncated messages above, widen view.TailLimit and rebuild.
870 // The chat cache is keyed on tailLimit so the next cachedChatLocked
871 // will re-issue Build instead of returning the stale slice. We
872 // rebuild immediately so the same redraw shows the freshly-revealed
873 // rows; otherwise the user would have to scroll again to see them.
874 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
875 prevLen := len(chat)
876 i.view.TailLimit += resumeTailExpandStep
877 if i.view.TailLimit >= len(i.view.Messages) {
878 i.view.TailLimit = 0 // unbounded
879 }
880 i.chatCacheValid = false
881 chat = i.cachedChatLocked(mainCols)
882 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
883 chat = chat[:len(chat)-1]
884 }
885 // Newly-revealed rows are older messages prepended at the top.
886 // scrollOffset counts rows from the bottom, so to keep the
887 // viewport visually anchored on the same content the user was
888 // looking at we shift it up by the number of rows added. Keep
889 // the auto-follow baseline (prevChatLen) in sync with the
890 // post-expansion length too, so the next render's follow guard
891 // doesn't see this growth as a synthetic delta and yank the
892 // viewport again.
893 if grew := len(chat) - prevLen; grew > 0 {
894 i.scrollOffset += grew
895 }
896 i.prevChatLen = len(chat)
897 i.prevChatCols = mainCols
898 maxOffset = len(chat) - chatRows
899 if maxOffset < 0 {
900 maxOffset = 0
901 }
902 }
903 if i.scrollOffset > maxOffset {
904 i.scrollOffset = maxOffset
905 }
906 if i.scrollOffset < 0 {
907 i.scrollOffset = 0
908 }
909
910 var visibleChat []string
911 if len(chat) <= chatRows {
912 visibleChat = chat
913 } else {
914 end := len(chat) - i.scrollOffset
915 rawStart := end - chatRows
916 if rawStart < 0 {
917 rawStart = 0
918 }
919 start := snapViewportStartToImageBlock(chat, rawStart)
920 // If the snap pulled start upward (an image-block was atomic) while
921 // the user is scrolling downward, the viewport would sit on the same
922 // image until the user mashes down past every reserved row. Bump
923 // scrollOffset past the image so one keypress always clears it.
924 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
925 jump := rawStart - start
926 i.scrollOffset -= jump
927 if i.scrollOffset < 0 {
928 i.scrollOffset = 0
929 }
930 end = len(chat) - i.scrollOffset
931 rawStart = end - chatRows
932 if rawStart < 0 {
933 rawStart = 0
934 }
935 start = snapViewportStartToImageBlock(chat, rawStart)
936 }
937 end = start + chatRows
938 if end > len(chat) {
939 end = len(chat)
940 start = end - chatRows
941 if start < 0 {
942 start = 0
943 }
944 }
945 visibleChat = chat[start:end]
946 }
947 i.prevScrollOffset = i.scrollOffset
948 visibleChat = clipBottomClippedImages(visibleChat)
949
950 // A tiny "scrolled up" indicator in the top-right of the chat pane
951 // so you know you're not at the bottom. When the viewport was
952 // parked by /jump we include the turn number so the user remembers
953 // they're reading history rather than the live conversation.
954 if i.scrollOffset > 0 && len(visibleChat) > 0 {
955 var text string
956 if i.parkedTurn > 0 && i.parkedTotal > 0 {
957 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
958 i.parkedTurn, i.parkedTotal, i.scrollOffset)
959 } else {
960 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
961 }
962 note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
963 visibleChat = append([]string{note}, visibleChat...)
964 if len(visibleChat) > chatRows {
965 visibleChat = visibleChat[:chatRows]
966 }
967 }
968
969 // Default: the real terminal cursor sits on the main editor's
970 // input position. In main-screen log mode cursor rows are relative
971 // to the fixed bottom band, not the chat transcript.
972 // Floating content has its own coordinate space, so dialog cursor rows
973 // remain relative to the content passed to FloatingPane.Compose.
974 dialogLead := 0
975 cursorRow := inputStartRow + inputCursorOffset + curR
976 cursorCol := curC + inputCursorColOffset
977 cursorInDialog := false
978 if i.btwDialog.Active() {
979 if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 {
980 cursorRow = dialogLead + r
981 cursorCol = c
982 cursorInDialog = true
983 }
984 }
985 if i.dialog.Active() {
986 if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 {
987 cursorRow = dialogLead + r
988 cursorCol = c
989 cursorInDialog = true
990 }
991 }
992 if i.llamaDialog.Active() {
993 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
994 cursorRow = dialogLead + r
995 cursorCol = c
996 cursorInDialog = true
997 } else {
998 cursorRow = -1
999 cursorCol = 0
1000 }
1001 }
1002 if i.sessionDialog.Active() {
1003 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1004 cursorRow = dialogLead + r
1005 cursorCol = c
1006 cursorInDialog = true
1007 }
1008 }
1009 if i.sessionTreeDialog.Active() {
1010 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1011 cursorRow = dialogLead + r
1012 cursorCol = c
1013 cursorInDialog = true
1014 }
1015 }
1016 if i.extPanel.Active() {
1017 cursorRow = -1
1018 cursorCol = 0
1019 }
1020 dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
1021 if cursorInDialog {
1022 dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol
1023 if dialogCursorRow < 0 {
1024 dialogCursorRow = -1
1025 }
1026 }
1027 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
1028 if i.renderRevision.Load() != renderRevision {
1029 i.mu.Unlock()
1030 return
1031 }
1032 theme := i.cfg.Theme
1033 // State assembly above is synchronized; terminal output is owned by
1034 // this renderer goroutine and must not hold the global state mutex.
1035 i.mu.Unlock()
1036 if modalBackdrop {
1037 background := floatingBackgroundFrame(visibleChat, bottom, rows)
1038 pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
1039 i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)
1040 } else {
1041 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1042 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1043 }
1044 i.mu.Lock()
1045 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1046 alert := *i.pendingAlert
1047 i.pendingAlert = nil
1048 i.emitAlertLocked(alert)
1049 }
1050 i.mu.Unlock()
1051}
1052
1053// floatingOverlayTitle supplies every overlay with a stable, human-readable
1054// title even when its body does not use the legacy frame-header convention.
1055func floatingOverlayTitle(id string) string {
1056 return strings.ReplaceAll(id, "-", " ")
1057}
1058
1059// floatingDialogBody removes a legacy dialog's rule chrome and returns its
1060// title for the shared floating-pane border.
1061func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
1062 if len(lines) == 0 {
1063 return "", nil, 0
1064 }
1065 body = lines
1066 if isFrameHeaderLine(body[0]) {
1067 plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ")
1068 title = strings.TrimSpace(strings.TrimRight(plain, "─"))
1069 body = body[1:]
1070 removedTopRows = 1
1071 }
1072 if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) {
1073 body = body[:last]
1074 }
1075 return title, body, removedTopRows
1076}
1077
1078// floatingBackgroundFrame preserves the regular viewport layout while an
1079// independent view is open. It is a fixed terminal frame only for the overlay
1080// lifetime; normal rendering immediately returns to scrollback-oriented flow
1081// when the pane closes.
1082func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
1083 if rows < 1 {
1084 return nil
1085 }
1086 // Match DrawLog's logical order and renderer-owned bottom margin, then
1087 // retain exactly the terminal viewport. In particular, an editor/status
1088 // band taller than the terminal must show its newest rows, not its oldest.
1089 lines := make([]string, 0, len(chat)+len(bottom)+1)
1090 lines = append(lines, chat...)
1091 lines = append(lines, bottom...)
1092 lines = append(lines, "")
1093 if len(lines) > rows {
1094 lines = lines[len(lines)-rows:]
1095 }
1096 frame := make([]string, rows)
1097 // DrawLog's flow renderer leaves the logical session content at the
1098 // viewport's top. Keep that anchoring when switching to a fixed floating
1099 // frame so opening a pane does not visibly jump the whole session down.
1100 copy(frame, lines)
1101 return frame
1102}
1103
1104func hasImageEscape(line string) bool {
1105 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1106}
1107func snapViewportStartToImageBlock(chat []string, start int) int {
1108 if start <= 0 || start >= len(chat) {
1109 return start
1110 }
1111 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1112 return start
1113 }
1114 for k := start - 1; k >= 0; k-- {
1115 line := chat[k]
1116 if hasImageEscape(line) {
1117 return k
1118 }
1119 if !isBoxBlankLine(line) {
1120 break
1121 }
1122 }
1123 return start
1124}
1125func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1126 if len(msgs) == 0 {
1127 return nil
1128 }
1129 out := make([]provider.Message, 0, len(msgs))
1130 for _, m := range msgs {
1131 if isHiddenTranscriptMessage(m) {
1132 continue
1133 }
1134 out = append(out, m)
1135 }
1136 return out
1137}
1138func isHiddenTranscriptMessage(m provider.Message) bool {
1139 if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
1140 return true
1141 }
1142 if m.Role != provider.RoleUser || len(m.Content) == 0 {
1143 return false
1144 }
1145 tb, ok := m.Content[0].(provider.TextBlock)
1146 if !ok {
1147 return false
1148 }
1149 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1150}
1151func clipBottomClippedImages(lines []string) []string {
1152 if len(lines) == 0 {
1153 return lines
1154 }
1155 out := append([]string(nil), lines...)
1156 for i, line := range out {
1157 if !hasImageEscape(line) {
1158 continue
1159 }
1160 // Image blocks render as: image escape, zero or more blank
1161 // reservation rows, then the muted "image - ..." info line,
1162 // then one trailing blank. If the info line isn't visible in
1163 // the current chat slice, the image would paint down into the
1164 // fixed status bar area. Suppress that image for this frame.
1165 //
1166 // When the image lives inside a tool box, the reservation rows
1167 // are wrapped in vertical box edges ("│ ... │"); those rows
1168 // look non-blank under a naive whitespace check but are still
1169 // reservation rows for this scan, so treat them as blank.
1170 foundInfo := false
1171 for j := i + 1; j < len(out); j++ {
1172 if strings.Contains(out[j], "image - ") {
1173 foundInfo = true
1174 break
1175 }
1176 if !isBoxBlankLine(out[j]) {
1177 break
1178 }
1179 }
1180 if !foundInfo {
1181 out[i] = ""
1182 }
1183 }
1184 return out
1185}
1186func isBoxBlankLine(line string) bool {
1187 stripped := stripANSIBytes(line)
1188 stripped = strings.TrimSpace(stripped)
1189 stripped = strings.Trim(stripped, "│")
1190 stripped = strings.TrimSpace(stripped)
1191 return stripped == ""
1192}
1193func stripANSIBytes(s string) string {
1194 if !strings.Contains(s, "\x1b") {
1195 return s
1196 }
1197 var b strings.Builder
1198 b.Grow(len(s))
1199 i := 0
1200 for i < len(s) {
1201 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1202 end := i + 2
1203 for end < len(s) {
1204 c := s[end]
1205 end++
1206 if c >= 0x40 && c <= 0x7e {
1207 break
1208 }
1209 }
1210 i = end
1211 continue
1212 }
1213 b.WriteByte(s[i])
1214 i++
1215 }
1216 return b.String()
1217}
1218func panelKeyName(k tui.Key) string {
1219 switch k.Kind {
1220 case tui.KeyUp:
1221 return "up"
1222 case tui.KeyDown:
1223 return "down"
1224 case tui.KeyLeft:
1225 return "left"
1226 case tui.KeyRight:
1227 return "right"
1228 case tui.KeyEnter:
1229 return "enter"
1230 case tui.KeyEsc:
1231 return "esc"
1232 case tui.KeyTab:
1233 return "tab"
1234 case tui.KeyBackspace:
1235 return "backspace"
1236 case tui.KeyDelete:
1237 return "delete"
1238 case tui.KeyHome:
1239 return "home"
1240 case tui.KeyEnd:
1241 return "end"
1242 case tui.KeyPageUp:
1243 return "pageup"
1244 case tui.KeyPageDown:
1245 return "pagedown"
1246 case tui.KeyRune:
1247 return "rune"
1248 default:
1249 return "unknown"
1250 }
1251}
1252func panelKeyText(k tui.Key) string {
1253 if k.Kind == tui.KeyRune {
1254 return string(k.Rune)
1255 }
1256 return ""
1257}
1258func truncateLine(s string, n int) string {
1259 if n <= 0 {
1260 return ""
1261 }
1262 // Collapse newlines — chips are single line.
1263 s = strings.ReplaceAll(s, "\n", " ↩ ")
1264 runes := []rune(s)
1265 if len(runes) <= n {
1266 return s
1267 }
1268 if n <= 3 {
1269 return strings.Repeat(".", n)
1270 }
1271 return string(runes[:n-3]) + "..."
1272}