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