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