v0.44.0 efd47b3

ponytail_setting_test.go RawHistoryFolder

1package modes
2
3import (
4	"errors"
5	"strings"
6	"testing"
7
8	"github.com/bnema/zut/packages/core"
9)
10
11type recordingPonytailSettingsStore struct {
12	SettingsStore
13	values   []bool
14	calls    int
15	err      error
16	failures map[int]error
17}
18
19func (s *recordingPonytailSettingsStore) SetPonytailEnabled(enabled bool) error {
20	s.calls++
21	if s.err != nil {
22		return s.err
23	}
24	if err := s.failures[s.calls]; err != nil {
25		return err
26	}
27	s.values = append(s.values, enabled)
28	return nil
29}
30
31func TestSettingsDialogOffersPonytailEnabledByDefault(t *testing.T) {
32	store := &recordingPonytailSettingsStore{}
33	interactive := NewInteractive(InteractiveConfig{
34		SettingsStore: store,
35		RefreshPrompt: func() error { return nil },
36	})
37	interactive.openSettingsDialog()
38
39	item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled")
40	if item == nil {
41		t.Fatal("settings dialog is missing Ponytail mode")
42	}
43	if !item.value {
44		t.Fatal("Ponytail mode should be enabled when the config value is absent")
45	}
46	if item.disabled {
47		t.Fatal("Ponytail mode should be available with persistence and refresh capabilities")
48	}
49}
50
51func TestSettingsDialogDisablesPonytailWithoutCapabilities(t *testing.T) {
52	cases := []struct {
53		name string
54		cfg  InteractiveConfig
55	}{
56		{name: "neither capability", cfg: InteractiveConfig{}},
57		{name: "persistence only", cfg: InteractiveConfig{SettingsStore: &recordingPonytailSettingsStore{}}},
58		{name: "refresh only", cfg: InteractiveConfig{RefreshPrompt: func() error { return nil }}},
59	}
60	for _, tc := range cases {
61		t.Run(tc.name, func(t *testing.T) {
62			interactive := NewInteractive(tc.cfg)
63			interactive.openSettingsDialog()
64
65			item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled")
66			if item == nil {
67				t.Fatal("settings dialog is missing Ponytail mode")
68			}
69			if !item.disabled {
70				t.Fatal("Ponytail mode should be disabled without both capabilities")
71			}
72			if item.hint == "" {
73				t.Fatal("disabled Ponytail mode should explain why it is unavailable")
74			}
75		})
76	}
77}
78
79func TestApplyPonytailToggleRefreshesAndPersists(t *testing.T) {
80	store := &recordingPonytailSettingsStore{}
81	refreshCalls := 0
82	interactive := NewInteractive(InteractiveConfig{
83		SettingsStore: store,
84		RefreshPrompt: func() error {
85			refreshCalls++
86			return nil
87		},
88	})
89	interactive.rend = nil
90
91	interactive.applySettingToggle("ponytail_enabled", false)
92	if interactive.ponytailEnabled() {
93		t.Fatal("Ponytail mode remained enabled after disabling it")
94	}
95	interactive.applySettingToggle("ponytail_enabled", true)
96	if !interactive.ponytailEnabled() {
97		t.Fatal("Ponytail mode remained disabled after enabling it")
98	}
99	if refreshCalls != 2 {
100		t.Fatalf("refresh calls = %d, want 2", refreshCalls)
101	}
102	if len(store.values) != 2 || store.values[0] || !store.values[1] {
103		t.Fatalf("persisted values = %v, want [false true]", store.values)
104	}
105	if interactive.statusErr != "" {
106		t.Fatalf("unexpected status error: %q", interactive.statusErr)
107	}
108}
109
110func TestApplyPonytailToggleRollsBackWhenRefreshFails(t *testing.T) {
111	store := &recordingPonytailSettingsStore{}
112	refreshCalls := 0
113	interactive := NewInteractive(InteractiveConfig{
114		SettingsStore: store,
115		RefreshPrompt: func() error {
116			refreshCalls++
117			if refreshCalls == 1 {
118				return errors.New("refresh failed")
119			}
120			return nil
121		},
122	})
123	interactive.rend = nil
124
125	interactive.applySettingToggle("ponytail_enabled", false)
126	if !interactive.ponytailEnabled() {
127		t.Fatal("Ponytail mode stayed disabled after refresh rollback")
128	}
129	if refreshCalls != 2 {
130		t.Fatalf("refresh calls = %d, want initial refresh and rollback refresh", refreshCalls)
131	}
132	if len(store.values) != 2 || store.values[0] || !store.values[1] {
133		t.Fatalf("persisted rollback values = %v, want [false true]", store.values)
134	}
135	if !strings.Contains(interactive.statusErr, "refresh failed") {
136		t.Fatalf("status error = %q, want refresh failure", interactive.statusErr)
137	}
138}
139
140func TestApplyPonytailToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) {
141	store := &recordingPonytailSettingsStore{
142		failures: map[int]error{2: errors.New("rollback disk full")},
143	}
144	refreshCalls := 0
145	interactive := NewInteractive(InteractiveConfig{
146		SettingsStore: store,
147		RefreshPrompt: func() error {
148			refreshCalls++
149			if refreshCalls == 1 {
150				return errors.New("refresh failed")
151			}
152			return nil
153		},
154	})
155	interactive.rend = nil
156
157	interactive.applySettingToggle("ponytail_enabled", false)
158	if interactive.ponytailEnabled() {
159		t.Fatal("in-memory setting reverted despite rollback persistence failure")
160	}
161	if len(store.values) != 1 || store.values[0] {
162		t.Fatalf("durable writes = %v, want only the successful disable", store.values)
163	}
164	if refreshCalls != 2 {
165		t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls)
166	}
167	if !strings.Contains(interactive.statusErr, "rollback disk full") {
168		t.Fatalf("status error = %q, want rollback failure", interactive.statusErr)
169	}
170}
171
172func TestApplyPonytailToggleKeepsStateWhenPersistenceFails(t *testing.T) {
173	store := &recordingPonytailSettingsStore{err: errors.New("disk full")}
174	refreshCalls := 0
175	interactive := NewInteractive(InteractiveConfig{
176		SettingsStore: store,
177		RefreshPrompt: func() error {
178			refreshCalls++
179			return nil
180		},
181	})
182	interactive.rend = nil
183
184	interactive.applySettingToggle("ponytail_enabled", false)
185	if !interactive.ponytailEnabled() {
186		t.Fatal("Ponytail mode changed despite persistence failure")
187	}
188	if refreshCalls != 0 {
189		t.Fatalf("refresh calls = %d, want none", refreshCalls)
190	}
191	if !strings.Contains(interactive.statusErr, "disk full") {
192		t.Fatalf("status error = %q, want persistence failure", interactive.statusErr)
193	}
194}
195
196func TestApplyPonytailToggleUpdatesLiveAgentPrompt(t *testing.T) {
197	const ponytailBlock = "[ponytail test block]"
198	store := &recordingPonytailSettingsStore{}
199	ag := core.NewAgent(nil, "model", "base\n\n"+ponytailBlock, nil)
200	var interactive *Interactive
201	refreshCalls := 0
202	interactive = NewInteractive(InteractiveConfig{
203		Agent:         ag,
204		SettingsStore: store,
205		RefreshPrompt: func() error {
206			refreshCalls++
207			system := "base"
208			if interactive.ponytailEnabled() {
209				system += "\n\n" + ponytailBlock
210			}
211			ag.SetSystemPrompt(system)
212			return nil
213		},
214	})
215	interactive.rend = nil
216
217	interactive.applySettingToggle("ponytail_enabled", false)
218	system, _ := ag.PromptConfig()
219	if strings.Contains(system, ponytailBlock) {
220		t.Fatalf("live prompt still contains disabled Ponytail block: %q", system)
221	}
222	interactive.applySettingToggle("ponytail_enabled", true)
223	system, _ = ag.PromptConfig()
224	if strings.Count(system, ponytailBlock) != 1 {
225		t.Fatalf("live prompt block count = %d, want 1: %q", strings.Count(system, ponytailBlock), system)
226	}
227	if refreshCalls != 2 {
228		t.Fatalf("refresh calls = %d, want 2", refreshCalls)
229	}
230}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close