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