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