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