1package tui
2
3import (
4 "io"
5 "sort"
6 "strings"
7)
8
9const (
10 // RightBarSeparatorWidth is the one-cell divider between the main pane
11 // and the host-owned right bar.
12 RightBarSeparatorWidth = 1
13 // RightBarMinWidth keeps widget titles and checklist markers readable.
14 RightBarMinWidth = 24
15 // RightBarMaxWidth prevents a long extension line from taking most of
16 // the transcript on wide terminals.
17 RightBarMaxWidth = 36
18 // RightBarMinMainWidth preserves enough room for the editor and chat.
19 RightBarMinMainWidth = 48
20
21 // rightBarRightPadding leaves a small gutter between widget text and the
22 // terminal's right edge.
23 rightBarRightPadding = 1
24)
25
26// RightBarWidget is declarative content for one persistent extension widget.
27// The host owns ordering and layout; widgets are display-only in the first
28// version and should use OpenPanel for interaction.
29type RightBarWidget struct {
30 Extension string
31 ID string
32 Title string
33 Lines []string
34}
35
36// RightBarColumns returns the main-pane and side-rail widths for a terminal.
37// It returns ok=false when keeping both panes usable would leave too little
38// room for the rail; callers should then render right-bar widgets using their
39// normal fallback placement.
40func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
41 if cols <= 0 {
42 return cols, 0, false
43 }
44 available := cols - RightBarSeparatorWidth
45 if available <= RightBarMinMainWidth {
46 return cols, 0, false
47 }
48
49 rightBarWidth = cols / 3
50 if rightBarWidth < RightBarMinWidth {
51 rightBarWidth = RightBarMinWidth
52 }
53 if rightBarWidth > RightBarMaxWidth {
54 rightBarWidth = RightBarMaxWidth
55 }
56 if available-rightBarWidth < RightBarMinMainWidth {
57 rightBarWidth = available - RightBarMinMainWidth
58 }
59 if rightBarWidth < RightBarMinWidth {
60 return cols, 0, false
61 }
62 return available - rightBarWidth, rightBarWidth, true
63}
64
65// RenderRightBar renders a bounded, full-height side rail without adding a
66// second frame around the host-owned separator. Widgets are sorted by
67// extension name and ID so output stays deterministic even when extension
68// frames arrive concurrently. Long lines are clipped with a three-dot ellipsis
69// while reserving one cell of right padding.
70func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
71 if width <= 0 || height <= 0 {
72 return nil
73 }
74
75 contentWidth := width - rightBarRightPadding
76 ordered := append([]RightBarWidget(nil), widgets...)
77 sort.SliceStable(ordered, func(a, b int) bool {
78 if ordered[a].Extension != ordered[b].Extension {
79 return ordered[a].Extension < ordered[b].Extension
80 }
81 return ordered[a].ID < ordered[b].ID
82 })
83
84 content := make([]string, 0, height)
85 truncated := false
86 padLine := func(text string, phase bool) string {
87 if contentWidth <= 0 {
88 return strings.Repeat(" ", width)
89 }
90 if phase {
91 text = truncateRightBarPhaseLine(text, contentWidth)
92 } else {
93 text = truncateRightBarLine(text, contentWidth)
94 }
95 if visible := visibleWidth(text); visible < width {
96 text += strings.Repeat(" ", width-visible)
97 }
98 return text
99 }
100 paintLine := func(text string, color *TerminalColor, dim, phase bool) string {
101 text = padLine(text, phase)
102 if color == nil {
103 return text
104 }
105 paint := func(value string, valueColor TerminalColor) string {
106 if dim {
107 value = Dim(value)
108 }
109 return th.FGColor(valueColor, value)
110 }
111 if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
112 if phase {
113 if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
114 return paint(text[:markerStart], *color) +
115 paint(text[markerStart:markerEnd], th.Muted) +
116 paint(text[markerEnd:nameStart], *color) +
117 paint(Bold(text[nameStart:nameEnd]), *color) +
118 paint(text[nameEnd:], *color)
119 }
120 }
121 return paint(text[:markerStart], *color) +
122 paint(text[markerStart:markerEnd], th.Muted) +
123 paint(text[markerEnd:], *color)
124 }
125 if phase {
126 return paint(Bold(text), *color)
127 }
128 return paint(text, *color)
129 }
130 appendContent := func(text string, color *TerminalColor, dim, phase bool) bool {
131 text = strings.ReplaceAll(text, "\r", "")
132 for _, line := range strings.Split(text, "\n") {
133 if len(content) >= height {
134 truncated = true
135 return false
136 }
137 content = append(content, paintLine(line, color, dim, phase))
138 }
139 return true
140 }
141
142 for idx, widget := range ordered {
143 if idx > 0 && !appendContent("", nil, false, false) {
144 break
145 }
146 label := "[" + widget.Extension + "]"
147 if widget.Title != "" {
148 label += " " + widget.Title
149 }
150 if !appendContent(label, &th.Accent, false, false) {
151 break
152 }
153 if !appendContent("", nil, false, false) {
154 break
155 }
156 activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
157 if activePhaseIndent >= 0 {
158 activePhaseIndent++ // host-owned line prefix
159 }
160 phaseIndent := rightBarChecklistIndent(widget.Lines)
161 if phaseIndent >= 0 {
162 phaseIndent++ // host-owned line prefix
163 }
164 activePhase := false
165 for _, line := range widget.Lines {
166 line = " " + line
167 dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
168 phase := rightBarChecklistPhaseLine(line, phaseIndent)
169 if !appendContent(line, &th.FG, dim, phase) {
170 break
171 }
172 }
173 if truncated {
174 break
175 }
176 }
177 if truncated {
178 marker := paintLine("... right bar ...", &th.Muted, false, false)
179 if len(content) == height {
180 content[len(content)-1] = marker
181 } else {
182 content = append(content, marker)
183 }
184 }
185 for len(content) < height {
186 content = append(content, strings.Repeat(" ", width))
187 }
188 return content
189}
190
191func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) {
192 start = 0
193 for start < len(text) && text[start] == ' ' {
194 start++
195 }
196 if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' {
197 return 0, 0, 0, false
198 }
199 switch text[start+1] {
200 case ' ', 'x', 'X', '>':
201 return start, start + 3, text[start+1], true
202 default:
203 return 0, 0, 0, false
204 }
205}
206
207func truncateRightBarLine(text string, width int) string {
208 if width <= 0 || visibleWidth(text) <= width {
209 return text
210 }
211 if width <= 3 {
212 return truncateToWidth("...", width)
213 }
214 return truncateToWidth(text, width-3) + "..."
215}
216
217func truncateRightBarPhaseLine(text string, width int) string {
218 if width <= 0 || visibleWidth(text) <= width {
219 return text
220 }
221 nameStart, nameEnd, ok := rightBarChecklistPhaseName(text)
222 if !ok {
223 return truncateRightBarLine(text, width)
224 }
225 prefix := text[:nameStart]
226 suffix := text[nameEnd:]
227 nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)
228 if nameWidth == 0 {
229 return prefix + suffix
230 }
231 if nameWidth < 0 {
232 return truncateRightBarLine(text, width)
233 }
234 return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix
235}
236
237func rightBarChecklistIndent(lines []string) int {
238 indent := -1
239 for _, line := range lines {
240 start, _, _, ok := rightBarChecklistMarker(line)
241 if ok && (indent < 0 || start < indent) {
242 indent = start
243 }
244 }
245 return indent
246}
247
248func rightBarChecklistPhaseLine(text string, phaseIndent int) bool {
249 if phaseIndent < 0 {
250 return false
251 }
252 start, _, _, ok := rightBarChecklistMarker(text)
253 return ok && start == phaseIndent
254}
255
256func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
257 _, markerEnd, _, markerOK := rightBarChecklistMarker(text)
258 if !markerOK {
259 return 0, 0, false
260 }
261 start = markerEnd
262 if start < len(text) && text[start] == ' ' {
263 start++
264 }
265 trimmedEnd := len(strings.TrimRight(text, " "))
266 if trimmedEnd <= start {
267 return 0, 0, false
268 }
269 suffix := text[start:trimmedEnd]
270 separator := strings.LastIndex(suffix, " ")
271 if separator < 0 {
272 return rightBarFallbackPhaseName(text, start, trimmedEnd)
273 }
274 nameEnd := start + separator
275 progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
276 if !rightBarProgressToken(progress) {
277 return rightBarFallbackPhaseName(text, start, trimmedEnd)
278 }
279 for nameEnd > start && text[nameEnd-1] == ' ' {
280 nameEnd--
281 }
282 if nameEnd <= start {
283 return 0, 0, false
284 }
285 return start, nameEnd, true
286}
287
288func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) {
289 for end > start && text[end-1] == ' ' {
290 end--
291 }
292 if end-start >= 3 && text[end-3:end] == "..." {
293 end -= 3
294 }
295 for end > start && text[end-1] == ' ' {
296 end--
297 }
298 if end <= start {
299 return 0, 0, false
300 }
301 return start, end, true
302}
303
304func rightBarProgressToken(value string) bool {
305 separator := strings.IndexByte(value, '/')
306 if separator <= 0 || separator == len(value)-1 {
307 return false
308 }
309 for _, part := range []string{value[:separator], value[separator+1:]} {
310 for _, r := range part {
311 if r < '0' || r > '9' {
312 return false
313 }
314 }
315 }
316 return true
317}
318
319func rightBarActivePhaseIndent(lines []string) int {
320 for _, line := range lines {
321 start, _, marker, ok := rightBarChecklistMarker(line)
322 if ok && marker == '>' {
323 return start
324 }
325 }
326 return -1
327}
328
329func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool {
330 if activePhaseIndent < 0 {
331 return false
332 }
333 start, _, marker, ok := rightBarChecklistMarker(text)
334 if !ok {
335 return false
336 }
337 if start == activePhaseIndent {
338 *activePhase = marker == '>'
339 return !*activePhase
340 }
341 if start > activePhaseIndent {
342 return !*activePhase
343 }
344 return false
345}
346
347// JoinRightBar combines one main-pane row and one right-bar row without
348// allowing either side to soft-wrap. The returned row occupies exactly
349// mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
350func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
351 return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false)
352}
353
354func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string {
355 if mainWidth < 1 {
356 mainWidth = 1
357 }
358 if rightBarWidth < 1 {
359 rightBarWidth = 1
360 }
361 main = truncateToWidth(main, mainWidth)
362 if visible := visibleWidth(main); visible < mainWidth {
363 main += strings.Repeat(" ", mainWidth-visible)
364 }
365 rightBar = truncateToWidth(rightBar, rightBarWidth)
366 if visible := visibleWidth(rightBar); visible < rightBarWidth {
367 rightBar += strings.Repeat(" ", rightBarWidth-visible)
368 }
369 separator := th.FGColor(th.Muted, "│")
370 if dimSeparator {
371 separator = Dim(separator)
372 }
373 return main + separator + rightBar
374}
375
376// DrawRightBar renders the complete transcript through DrawLog, then
377// composites the persistent rail over the visible viewport. The flow cache
378// remains authoritative so appended chat still enters native scrollback.
379func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
380 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
381}
382
383// DrawRightBarDimmed renders a right-bar frame whose separator is part of a
384// dimmed backdrop. Callers must pass already-dimmed main and right-bar rows.
385func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
386 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true)
387}
388
389func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) {
390 if r.cols == 0 || r.rows == 0 {
391 return
392 }
393 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
394 if !ok {
395 r.DrawLog(chat, bottom, cursorBottomRow, cursorCol)
396 return
397 }
398
399 if len(bottom) == 0 {
400 bottom = []string{""}
401 }
402
403 // Keep DrawLog's final blank margin unobstructed, matching the normal flow
404 // renderer and the previous right-bar layout.
405 frameRows := max(0, r.rows-1)
406 barLines := make([]string, frameRows)
407 copy(barLines, rightBar)
408
409 barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth ||
410 r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines)
411 contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth)
412 flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol
413 if flowUnchanged && !barChanged {
414 return
415 }
416 if !contentUnchanged && r.rightBarActive {
417 // Remove viewport chrome before DrawLog can naturally scroll a row into
418 // history. Otherwise every appended transcript row captures a stale
419 // copy of the rail in native terminal scrollback.
420 r.clearRightBarOverlay()
421 }
422 r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth)
423 if contentUnchanged && !barChanged {
424 // Cursor-only updates do not disturb the composited rail.
425 return
426 }
427 r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator)
428 r.rightBarActive = true
429 r.rightBarLines = append(r.rightBarLines[:0], barLines...)
430 r.rightBarMainCols = mainWidth
431 r.rightBarDimmed = dimSeparator
432}
433
434// clearRightBarOverlay removes viewport chrome before DrawLog expands back to
435// the terminal width. Restoring the tracked flow cursor keeps DrawLog's
436// hardware-row model valid across the intermediate absolute writes.
437func (r *Renderer) clearRightBarOverlay() {
438 if !r.rightBarActive {
439 return
440 }
441 remaining := r.cols - r.rightBarMainCols
442 if remaining > 0 {
443 blank := strings.Repeat(" ", remaining)
444 var w strings.Builder
445 w.WriteString(SeqSynchronizedOn)
446 w.WriteString(SeqHideCursor)
447 for row := 0; row < max(0, r.rows-1); row++ {
448 w.WriteString(MoveTo(row+1, r.rightBarMainCols+1))
449 w.WriteString("\x1b[0m")
450 w.WriteString(blank)
451 }
452 r.appendTrackedLogCursor(&w, r.rightBarMainCols)
453 w.WriteString(SeqSynchronizedOff)
454 _, _ = io.WriteString(r.out, w.String())
455 }
456 r.rightBarActive = false
457 r.rightBarLines = nil
458 r.rightBarMainCols = 0
459 r.rightBarDimmed = false
460}
461
462func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) {
463 var w strings.Builder
464 w.WriteString(SeqSynchronizedOn)
465 w.WriteString(SeqHideCursor)
466 for row, line := range rightBar {
467 w.WriteString(MoveTo(row+1, mainWidth+1))
468 w.WriteString("\x1b[0m")
469 w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator))
470 }
471 r.appendTrackedLogCursor(&w, mainWidth)
472 w.WriteString(SeqSynchronizedOff)
473 _, _ = io.WriteString(r.out, w.String())
474}
475
476func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) {
477 if !r.logInit || r.cursorRow < 0 {
478 return
479 }
480 row := len(r.logChat) + r.cursorRow - r.logViewportTop
481 if row < 0 || row >= r.rows {
482 return
483 }
484 cursorCol := r.cursorCol
485 if cursorCol < 0 {
486 cursorCol = 0
487 }
488 if cursorCol >= contentCols {
489 cursorCol = contentCols - 1
490 }
491 w.WriteString(MoveTo(row+1, cursorCol+1))
492 w.WriteString(SeqShowCursor)
493}