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