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