1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "github.com/bnema/zut/packages/tui"
10)
11
12func newNotesTestInteractive() *Interactive {
13 i := &Interactive{dirty: make(chan struct{}, 1)}
14 i.cfg.Theme = tui.Theme{
15 Muted: tui.Color256(8), Warning: tui.Color256(3), Error: tui.Color256(1),
16 Tool: tui.Color256(2), Accent: tui.Color256(4),
17 }
18 return i
19}
20
21func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) {
22 i := newNotesTestInteractive()
23
24 i.Notify("kagi", "info", "pending")
25 i.Notify("kagi", "success", "approved")
26 i.Notify("other", "info", "keep me")
27
28 if len(i.extNotes) != 3 {
29 t.Fatalf("expected 3 notes, got %d", len(i.extNotes))
30 }
31
32 i.ClearNotes("kagi")
33
34 if len(i.extNotes) != 1 {
35 t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes)
36 }
37 if !strings.Contains(i.extNotes[0], "[other] ") {
38 t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0])
39 }
40}
41
42func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
43 i := newNotesTestInteractive()
44 i.Notify("kagi", "info", "pending")
45
46 i.ClearNotes("nope")
47
48 if len(i.extNotes) != 1 {
49 t.Fatalf("expected note to survive, got %d", len(i.extNotes))
50 }
51}
52
53func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) {
54 i := newNotesTestInteractive()
55 lines := make([]string, 20)
56 for idx := range lines {
57 lines[idx] = "line"
58 }
59 i.SetWidget("extension", "widget", "above_input", "Title", lines)
60
61 i.mu.Lock()
62 got := i.extensionChromeLinesLocked(80)
63 i.mu.Unlock()
64 if len(got) != maxExtensionWidgetRows {
65 t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got)
66 }
67 if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") {
68 t.Fatalf("bounded widget rows omitted truncation marker: %v", got)
69 }
70}
71
72func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) {
73 i := newNotesTestInteractive()
74 for n := 0; n < maxExtensionStatusRows+4; n++ {
75 i.SetStatus("extension", fmt.Sprintf("status-%d", n), "info", "line")
76 }
77
78 i.mu.Lock()
79 got := i.extensionChromeLinesLocked(80)
80 i.mu.Unlock()
81 if len(got) != maxExtensionStatusRows {
82 t.Fatalf("status rows = %d, want %d: %v", len(got), maxExtensionStatusRows, got)
83 }
84 if !strings.Contains(strings.Join(got, "\n"), "extension statuses truncated") {
85 t.Fatalf("bounded status rows omitted truncation marker: %v", got)
86 }
87}
88
89func TestNarrowRightBarFallbackCapsCombinedExtensionChrome(t *testing.T) {
90 i := newNotesTestInteractive()
91 for n := 0; n < 3; n++ {
92 lines := make([]string, 4)
93 for row := range lines {
94 lines[row] = fmt.Sprintf("extension-%d-line-%d", n, row)
95 }
96 i.SetWidget(fmt.Sprintf("extension-%d", n), "plan", "right_bar", "Plan", lines)
97 }
98 i.SetStatus("status-extension", "progress", "info", "still visible")
99
100 i.mu.Lock()
101 got := i.extensionChromeLinesForLayoutLocked(72, false, true)
102 i.mu.Unlock()
103 if len(got) != maxNarrowExtensionChromeRows {
104 t.Fatalf("narrow fallback rows = %d, want %d: %v", len(got), maxNarrowExtensionChromeRows, got)
105 }
106 if !strings.Contains(strings.Join(got, "\n"), "extension chrome truncated") {
107 t.Fatalf("narrow fallback omitted truncation marker: %v", got)
108 }
109}
110
111func TestPersistentExtensionStatusAndWidget(t *testing.T) {
112 i := newNotesTestInteractive()
113 i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
114 i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
115
116 i.mu.Lock()
117 lines := i.extensionChromeLinesLocked(80)
118 statusKey := i.extensionStatusesKeyLocked()
119 widgetKey := i.extensionWidgetsKeyLocked()
120 i.mu.Unlock()
121 joined := strings.Join(lines, "\n")
122 if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") {
123 t.Fatalf("persistent extension chrome = %q", joined)
124 }
125 if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") {
126 t.Fatalf("status cache key = %q", statusKey)
127 }
128 if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
129 t.Fatalf("widget cache key = %q", widgetKey)
130 }
131
132 i.SetStatus("tasked-phases", "progress", "", "")
133 i.ClearWidget("tasked-phases", "plan")
134 if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
135 t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
136 }
137}
138
139func TestRightBarWidgetsAreDeterministicAndFallbackAboveInput(t *testing.T) {
140 i := newNotesTestInteractive()
141 i.SetWidget("zeta", "plan", "right_bar", "Zeta", []string{"zeta line"})
142 i.SetWidget("alpha", "second", "right_bar", "Second", []string{"second line"})
143 i.SetWidget("alpha", "first", "right_bar", "First", []string{"first line"})
144 i.SetWidget("legacy", "plan", "above_input", "Legacy", []string{"legacy line"})
145
146 i.mu.Lock()
147 widgets := i.rightBarWidgetsLocked()
148 wideAbove := i.extensionChromeLinesAtLocked(80, true)
149 narrowFallback := i.extensionChromeLinesAtLocked(72, false)
150 i.mu.Unlock()
151
152 if len(widgets) != 3 || widgets[0].Extension != "alpha" || widgets[0].ID != "first" || widgets[1].ID != "second" || widgets[2].Extension != "zeta" {
153 t.Fatalf("right-bar ordering = %+v", widgets)
154 }
155 wideText := strings.Join(wideAbove, "\n")
156 if strings.Contains(wideText, "Zeta") || strings.Contains(wideText, "Second") || strings.Contains(wideText, "First") {
157 t.Fatalf("right-bar widgets leaked into wide above-input chrome: %q", wideText)
158 }
159 fallbackText := strings.Join(narrowFallback, "\n")
160 if !strings.Contains(fallbackText, "Zeta") || !strings.Contains(fallbackText, "First") || !strings.Contains(fallbackText, "Legacy") {
161 t.Fatalf("narrow fallback omitted widgets: %q", fallbackText)
162 }
163}
164
165func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) {
166 i := newNotesTestInteractive()
167 i.SetStatus("gone", "progress", "success", "done")
168 i.SetWidget("gone", "plan", "right_bar", "Gone", []string{"old"})
169 i.SetStatus("keep", "progress", "info", "still here")
170 i.SetWidget("keep", "plan", "right_bar", "Keep", []string{"new"})
171 i.extNotes = []string{"[gone] old note", "[keep] keep note"}
172
173 i.ClearExtensionChrome("gone")
174
175 if _, ok := i.extStatuses["gone"]; ok {
176 t.Fatal("exited extension status survived")
177 }
178 if _, ok := i.extWidgets["gone"]; ok {
179 t.Fatal("exited extension widget survived")
180 }
181 if _, ok := i.extStatuses["keep"]; !ok {
182 t.Fatal("unrelated extension status was removed")
183 }
184 if _, ok := i.extWidgets["keep"]; !ok {
185 t.Fatal("unrelated extension widget was removed")
186 }
187 if got := strings.Join(i.extNotes, "\n"); got != "[keep] keep note" {
188 t.Fatalf("extension notes after cleanup = %q", got)
189 }
190}
191
192func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
193 term := &alertTestTerminal{}
194 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
195 i.rend.Resize(80, 24)
196 i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
197 i.redraw()
198
199 out := stripANSIBytes(term.String())
200 if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") {
201 t.Fatalf("wide redraw omitted right-bar content: %q", out)
202 }
203 if !strings.Contains(out, "│") {
204 t.Fatal("wide redraw omitted the main-pane divider")
205 }
206}
207
208func TestCtrlBTogglesRightBarAndFallsBackAboveInput(t *testing.T) {
209 term := &alertTestTerminal{}
210 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
211 i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
212
213 if i.rightBarHidden {
214 t.Fatal("right bar starts hidden")
215 }
216 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}); done {
217 t.Fatal("ctrl+b unexpectedly exited interactive mode")
218 }
219 if !i.rightBarHidden {
220 t.Fatal("ctrl+b did not hide the right bar")
221 }
222
223 i.mu.Lock()
224 fallback := strings.Join(i.extensionChromeLinesForLayoutLocked(80, false, true), "\n")
225 i.mu.Unlock()
226 if !strings.Contains(fallback, "Plan") {
227 t.Fatalf("hidden right-bar widget did not fall back above input: %q", fallback)
228 }
229
230 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB})
231 if i.rightBarHidden {
232 t.Fatal("second ctrl+b did not show the right bar")
233 }
234}