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