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