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