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