1package tui
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7)
8
9func TestRightBarColumnsFallsBackWhenMainPaneWouldBeTooNarrow(t *testing.T) {
10 if _, _, ok := RightBarColumns(72); ok {
11 t.Fatal("right bar should fall back at 72 columns")
12 }
13 main, rail, ok := RightBarColumns(73)
14 if !ok {
15 t.Fatal("right bar should fit at 73 columns")
16 }
17 if main < RightBarMinMainWidth || rail < RightBarMinWidth || rail > RightBarMaxWidth {
18 t.Fatalf("right bar columns = main %d, rail %d", main, rail)
19 }
20 if main+RightBarSeparatorWidth+rail != 73 {
21 t.Fatalf("column budget = %d, want 73", main+RightBarSeparatorWidth+rail)
22 }
23}
24
25func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
26 lines := RenderRightBar(Dark, []RightBarWidget{
27 {Extension: "zeta", ID: "second", Title: "Z", Lines: []string{"z"}},
28 {Extension: "alpha", ID: "first", Title: "A", Lines: []string{"a"}},
29 }, 24, 7)
30 if len(lines) != 7 {
31 t.Fatalf("right bar rows = %d, want 7", len(lines))
32 }
33 for idx, line := range lines {
34 if width := visibleWidth(line); width != 24 {
35 t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line)
36 }
37 }
38 joined := stripANSI(strings.Join(lines, "\n"))
39 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
40 t.Fatalf("widgets are not deterministic by extension: %q", joined)
41 }
42 if strings.ContainsAny(joined, "┌┐└┘─") {
43 t.Fatalf("right bar added an outer border: %q", joined)
44 }
45
46 truncated := RenderRightBar(Dark, []RightBarWidget{
47 {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}},
48 }, 24, 7)
49 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
50 t.Fatalf("truncation marker missing: %q", truncated)
51 }
52}
53
54func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
55 lines := RenderRightBar(Dark, []RightBarWidget{{
56 Extension: "plan",
57 Lines: []string{"one two three four five six"},
58 }}, 16, 8)
59
60 if len(lines) != 8 {
61 t.Fatalf("right bar rows = %d, want 8", len(lines))
62 }
63 plain := stripANSI(strings.Join(lines, "\n"))
64 if !strings.Contains(plain, " one two thr...") || strings.Contains(plain, "four five six") {
65 t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
66 }
67 for idx, line := range lines {
68 if width := visibleWidth(line); width != 16 {
69 t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line)
70 }
71 }
72}
73
74func TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) {
75 lines := RenderRightBar(Dark, []RightBarWidget{{
76 Extension: "tasked-phases",
77 Title: "p 0/1 | t 0/1",
78 Lines: []string{"[>] A phase title that is much too long to fit 0/1"},
79 }}, 24, 4)
80
81 for idx, line := range lines {
82 plain := stripANSI(line)
83 if !strings.HasSuffix(plain, " ") {
84 t.Fatalf("right bar line %d lacks right padding: %q", idx, plain)
85 }
86 if visibleWidth(strings.TrimRight(plain, " ")) > 23 {
87 t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain)
88 }
89 }
90}
91
92func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
93 lines := RenderRightBar(Dark, []RightBarWidget{
94 {
95 Extension: "tasked-phases",
96 Lines: []string{"[ ] A phase title that is much too long to fit 0/3"},
97 },
98 }, 24, 5)
99 plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n")
100 if len(plainLines) < 3 {
101 t.Fatalf("phase row is missing: %q", plainLines)
102 }
103 phase := strings.TrimRight(plainLines[2], " ")
104 if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") {
105 t.Fatalf("phase progress was clipped with its title: %q", phase)
106 }
107}
108
109func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) {
110 got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9)
111 if want := "[>] 0/1"; got != want {
112 t.Fatalf("truncated phase = %q, want %q", got, want)
113 }
114}
115
116func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
117 lines := RenderRightBar(Dark, []RightBarWidget{{
118 Extension: "tasked-phases",
119 Title: "p 0/1 | t 0/1",
120 Lines: []string{
121 "[>] Copper Lantern Setup 0/1",
122 " [ ] Sort the leftover star stickers",
123 },
124 }}, 36, 8)
125 plain := stripANSI(strings.Join(lines, "\n"))
126 if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
127 t.Fatalf("phase row has the wrong indentation: %q", plain)
128 }
129 plainLines := strings.Split(plain, "\n")
130 if len(plainLines) < 4 {
131 t.Fatalf("checklist rows are missing: %q", plain)
132 }
133 const expectedTask = " [ ] Sort the leftover star sti..."
134 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
135 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
136 }
137 if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
138 t.Fatalf("phase name is not bold: %q", lines)
139 }
140 if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
141 t.Fatalf("task text should not be bold: %q", lines)
142 }
143 if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
144 t.Fatalf("widget title is not followed by an empty row: %q", plain)
145 }
146}
147
148func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
149 lines := RenderRightBar(Dark, []RightBarWidget{{
150 Extension: "plan",
151 Lines: []string{"[x] done", "[>] active", "[ ] pending"},
152 }}, 24, 8)
153 rendered := strings.Join(lines, "\n")
154 for _, marker := range []string{"[x]", "[>]", "[ ]"} {
155 if !strings.Contains(rendered, Dark.FGColor(Dark.Muted, marker)) &&
156 !strings.Contains(rendered, Dark.FGColor(Dark.Muted, Dim(marker))) {
157 t.Fatalf("marker %q was not muted: %q", marker, rendered)
158 }
159 }
160 if !strings.Contains(stripANSI(rendered), " done") {
161 t.Fatalf("checklist text was not kept readable: %q", rendered)
162 }
163}
164
165func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
166 lines := RenderRightBar(Dark, []RightBarWidget{{
167 Extension: "plan",
168 Lines: []string{
169 "[x] finished phase",
170 " [x] finished task",
171 "[>] active phase",
172 " [>] active task",
173 "[ ] pending phase",
174 " [ ] pending task",
175 },
176 }}, 32, 12)
177 for _, line := range lines {
178 if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
179 if strings.Contains(line, "\x1b[2m") {
180 t.Fatalf("active checklist row was dimmed: %q", line)
181 }
182 }
183 }
184 plain := stripANSI(strings.Join(lines, "\n"))
185 for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
186 if !strings.Contains(plain, text) {
187 t.Fatalf("inactive checklist text %q missing: %q", text, plain)
188 }
189 }
190 if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
191 t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
192 }
193}
194
195func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
196 line := JoinRightBar(Dark, "main", "right", 8, 8)
197 if !strings.Contains(line, Dark.FGColor(Dark.Muted, "│")) {
198 t.Fatalf("separator is not muted: %q", line)
199 }
200 if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
201 t.Fatalf("joined right bar added an outer rule: %q", line)
202 }
203}
204
205func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
206 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
207 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
208 t.Fatalf("joined width = %d, want %d: %q", got, want, line)
209 }
210}
211
212func TestRightBarTruncationPreservesOSCSequences(t *testing.T) {
213 for _, tc := range []struct {
214 opener string
215 closer string
216 }{
217 {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"},
218 {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"},
219 } {
220 got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1)
221 if !strings.HasPrefix(got, tc.opener+"label") {
222 t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got)
223 }
224 got = truncateToWidth("12345"+tc.opener+"tail", 5)
225 if got != "12345" {
226 t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got)
227 }
228 got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5)
229 if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) {
230 t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got)
231 }
232 }
233}
234
235func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) {
236 t.Setenv("TERM_PROGRAM", "")
237
238 var out bytes.Buffer
239 r := NewRenderer(&out)
240 r.SetTheme(Dark)
241 r.Resize(80, 5)
242 chat := []string{"one", "two", "three", "four", "five", "six"}
243 r.DrawRightBar(
244 chat,
245 []string{"input"},
246 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
247 0,
248 0,
249 )
250 if !r.logInit {
251 t.Fatal("right-bar draw abandoned the flow renderer")
252 }
253 if got := len(r.logChat); got != len(chat) {
254 t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat))
255 }
256
257 out.Reset()
258 chat = append(chat, "seven")
259 r.DrawRightBar(
260 chat,
261 []string{"input"},
262 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
263 0,
264 0,
265 )
266 got := out.String()
267 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
268 t.Fatalf("right-bar append cleared retained scrollback: %q", got)
269 }
270 if !strings.Contains(stripANSI(got), "seven") {
271 t.Fatalf("right-bar append omitted new transcript row: %q", got)
272 }
273 mainWidth, _, _ := RightBarColumns(80)
274 clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
275 if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt {
276 t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got)
277 }
278 if r.logViewportTop == 0 {
279 t.Fatal("test did not exercise transcript rows above the viewport")
280 }
281}
282
283func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) {
284 t.Setenv("TERM_PROGRAM", "")
285 var out bytes.Buffer
286 r := NewRenderer(&out)
287 r.SetTheme(Dark)
288 r.Resize(80, 5)
289 chat := []string{"chat"}
290 bottom := []string{"input"}
291 rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
292 r.DrawRightBar(chat, bottom, rail, 0, 0)
293 out.Reset()
294
295 r.DrawRightBar(chat, bottom, rail, 0, 0)
296 if out.Len() != 0 {
297 t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String())
298 }
299}
300
301func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) {
302 t.Setenv("TERM_PROGRAM", "")
303 var out bytes.Buffer
304 r := NewRenderer(&out)
305 r.SetTheme(Dark)
306 r.Resize(80, 5)
307 chat := []string{"chat"}
308 bottom := []string{"input"}
309 rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
310 r.DrawRightBar(chat, bottom, rail, 0, 0)
311 out.Reset()
312
313 r.DrawRightBar(chat, bottom, rail, 0, 1)
314 if out.Len() == 0 {
315 t.Fatal("cursor-only right-bar update emitted nothing")
316 }
317 if !strings.Contains(out.String(), "\r\x1b[1C") {
318 t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String())
319 }
320 if strings.Contains(stripANSI(out.String()), "task") {
321 t.Fatalf("cursor-only update repainted the entire rail: %q", out.String())
322 }
323}
324
325func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) {
326 t.Setenv("TERM_PROGRAM", "")
327 var out bytes.Buffer
328 r := NewRenderer(&out)
329 r.SetTheme(Dark)
330 r.Resize(80, 5)
331 rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
332 r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100)
333
334 mainWidth, _, ok := RightBarColumns(80)
335 if !ok {
336 t.Fatal("right bar should fit")
337 }
338 got := out.String()
339 if strings.Contains(got, MoveTo(2, 101)) {
340 t.Fatalf("cursor escaped the main content area: %q", got)
341 }
342 if !strings.Contains(got, MoveTo(2, mainWidth)) {
343 t.Fatalf("cursor was not clamped to the main content edge: %q", got)
344 }
345}
346
347func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
348 t.Setenv("TERM_PROGRAM", "")
349
350 var out bytes.Buffer
351 r := NewRenderer(&out)
352 r.SetTheme(Dark)
353 r.Resize(80, 5)
354 r.DrawRightBar(
355 []string{"chat"},
356 []string{"input"},
357 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
358 0,
359 0,
360 )
361
362 out.Reset()
363 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
364 if r.rightBarActive {
365 t.Fatal("right-bar fallback left the viewport overlay active")
366 }
367 if r.logRawContentCols != 80 {
368 t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols)
369 }
370 mainWidth, _, ok := RightBarColumns(80)
371 if !ok {
372 t.Fatal("right bar should fit before falling back to DrawLog")
373 }
374 clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
375 if !strings.Contains(out.String(), clearedRail) {
376 t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String())
377 }
378 if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) {
379 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
380 }
381}
382
383func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
384 var out bytes.Buffer
385 r := NewRenderer(&out)
386 r.SetTheme(Dark)
387 r.Resize(80, 5)
388 r.DrawRightBar(
389 []string{"chat"},
390 []string{"input"},
391 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Old", Lines: []string{"first"}}}, 26, 5),
392 0,
393 0,
394 )
395 if !strings.Contains(stripANSI(out.String()), "Old") {
396 t.Fatalf("initial right-bar frame omitted widget: %q", out.String())
397 }
398
399 out.Reset()
400 r.DrawRightBar(
401 []string{"chat"},
402 []string{"input"},
403 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Updated", Lines: []string{"second"}}}, 26, 5),
404 0,
405 0,
406 )
407 if !strings.Contains(stripANSI(out.String()), "Updated") {
408 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
409 }
410 if !r.logInit {
411 t.Fatal("right-bar update abandoned the flow renderer")
412 }
413
414 out.Reset()
415 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
416 if !r.logInit {
417 t.Fatal("flow renderer was not retained after right-bar clearing")
418 }
419
420 out.Reset()
421 r.Resize(100, 6)
422 if out.Len() == 0 {
423 t.Fatal("resize did not invalidate the right-bar frame")
424 }
425 main, rail, ok := RightBarColumns(100)
426 if !ok {
427 t.Fatal("right bar should fit after resize")
428 }
429 if main+RightBarSeparatorWidth+rail != 100 {
430 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
431 }
432}
433
434func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) {
435 var out bytes.Buffer
436 r := NewRenderer(&out)
437 r.SetTheme(Dark)
438 r.Resize(80, 5)
439
440 r.DrawRightBarDimmed(
441 DimLines([]string{"chat"}),
442 DimLines([]string{"input"}),
443 DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)),
444 0,
445 0,
446 )
447
448 separator := Dim(Dark.FGColor(Dark.Muted, "│"))
449 if !strings.Contains(out.String(), separator) {
450 t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String())
451 }
452}