v0.34.0 a939a2a

session_title_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"strings"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13)
14
15type interactiveTitleClient struct {
16	mu       sync.Mutex
17	requests []provider.Request
18	title    string
19	titleErr error
20}
21
22func (c *interactiveTitleClient) Name() string { return "interactive-title-test" }
23
24func (c *interactiveTitleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
25	c.mu.Lock()
26	c.requests = append(c.requests, req)
27	c.mu.Unlock()
28	if req.System != "" && c.titleErr != nil {
29		return nil, c.titleErr
30	}
31	out := make(chan provider.Event, 2)
32	if req.System != "" {
33		out <- provider.EventTextDelta{Delta: c.title}
34		out <- provider.EventDone{Stop: provider.StopEnd}
35	} else {
36		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
37			Role:    provider.RoleAssistant,
38			Content: []provider.Content{provider.TextBlock{Text: "main response"}},
39		}}
40	}
41	close(out)
42	return out, nil
43}
44
45func (c *interactiveTitleClient) requestCount() int {
46	c.mu.Lock()
47	defer c.mu.Unlock()
48	return len(c.requests)
49}
50
51func waitInteractiveIdle(t *testing.T, i *Interactive) {
52	t.Helper()
53	deadline := time.Now().Add(time.Second)
54	for time.Now().Before(deadline) {
55		i.mu.Lock()
56		busy := i.busy
57		i.mu.Unlock()
58		if !busy {
59			return
60		}
61		time.Sleep(time.Millisecond)
62	}
63	t.Fatal("interactive turn did not finish")
64}
65
66func waitInteractiveTitle(t *testing.T, i *Interactive, want string) {
67	t.Helper()
68	deadline := time.Now().Add(time.Second)
69	for time.Now().Before(deadline) {
70		i.mu.Lock()
71		got := i.sessionTitle
72		i.mu.Unlock()
73		if got == want {
74			return
75		}
76		time.Sleep(time.Millisecond)
77	}
78	i.mu.Lock()
79	got := i.sessionTitle
80	i.mu.Unlock()
81	t.Fatalf("session title = %q, want %q", got, want)
82}
83
84func waitTerminalOutput(t *testing.T, term *alertTestTerminal, want string) {
85	t.Helper()
86	deadline := time.Now().Add(time.Second)
87	for time.Now().Before(deadline) {
88		if got := term.String(); got == want {
89			return
90		}
91		time.Sleep(time.Millisecond)
92	}
93	t.Fatalf("terminal title output = %q, want %q", term.String(), want)
94}
95
96func TestFirstRealPromptSetsAndPersistsTerminalTitle(t *testing.T) {
97	term := &alertTestTerminal{}
98	client := &interactiveTitleClient{title: "Fix login retries"}
99	ag := core.NewAgent(client, "test-model", "", nil)
100	persisted := make(chan string, 1)
101	i := NewInteractive(InteractiveConfig{
102		Agent:    ag,
103		Terminal: term,
104		PersistTitle: func(title string) error {
105			persisted <- title
106			return nil
107		},
108	})
109	i.markInteractiveStarted()
110
111	i.Submit("Please fix login retries")
112	waitInteractiveIdle(t, i)
113
114	select {
115	case got := <-persisted:
116		if got != "Fix login retries" {
117			t.Fatalf("persisted title = %q, want %q", got, "Fix login retries")
118		}
119	case <-time.After(time.Second):
120		t.Fatal("title was not persisted")
121	}
122	waitTerminalOutput(t, term, "\x1b]0;zut: Fix login retries\x07")
123	if got := len(ag.Messages()); got != 2 {
124		t.Fatalf("transcript message count = %d, want user + assistant only", got)
125	}
126	if got := client.requestCount(); got != 2 {
127		t.Fatalf("provider request count = %d, want main + hidden title", got)
128	}
129}
130
131func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) {
132	term := &alertTestTerminal{}
133	client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")}
134	ag := core.NewAgent(client, "test-model", "", nil)
135	persisted := make(chan string, 1)
136	i := NewInteractive(InteractiveConfig{
137		Agent:    ag,
138		Terminal: term,
139		PersistTitle: func(title string) error {
140			persisted <- title
141			return nil
142		},
143	})
144	i.markInteractiveStarted()
145
146	prompt := "Please fix the login retry behavior"
147	i.Submit(prompt)
148	waitInteractiveIdle(t, i)
149
150	select {
151	case got := <-persisted:
152		if got != "Please fix the login retry behavior" {
153			t.Fatalf("fallback title = %q, want prompt text", got)
154		}
155	case <-time.After(time.Second):
156		t.Fatal("fallback title was not persisted")
157	}
158	if got := len(ag.Messages()); got != 2 {
159		t.Fatalf("transcript message count = %d, want main turn only", got)
160	}
161}
162
163func TestSkillSlashPromptDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
164	term := &alertTestTerminal{}
165	client := &interactiveTitleClient{title: "should not be used"}
166	ag := core.NewAgent(client, "test-model", "", nil)
167	i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
168	i.markInteractiveStarted()
169
170	i.submitOrQueuePrompt(context.Background(), "expanded skill instructions")
171	waitInteractiveIdle(t, i)
172
173	if got := client.requestCount(); got != 1 {
174		t.Fatalf("provider request count = %d, want only the slash-expanded main request", got)
175	}
176	i.mu.Lock()
177	seen := i.titleRealPromptSeen
178	i.mu.Unlock()
179	if seen {
180		t.Fatal("slash-expanded prompt consumed the first real prompt title slot")
181	}
182}
183
184func TestStartupPreDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
185	term := &alertTestTerminal{}
186	client := &interactiveTitleClient{title: "real prompt title"}
187	ag := core.NewAgent(client, "test-model", "", nil)
188	i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
189	i.markInteractiveStarted()
190	i.awaitingStartupPre = true
191
192	i.Submit("load startup resources")
193	waitInteractiveIdle(t, i)
194
195	if got := client.requestCount(); got != 1 {
196		t.Fatalf("startup pre request count = %d, want only the startup request", got)
197	}
198	i.mu.Lock()
199	seen := i.titleRealPromptSeen
200	i.mu.Unlock()
201	if seen {
202		t.Fatal("startup pre consumed the first real prompt title slot")
203	}
204}
205
206func TestDisabledTerminalTitlesSkipHiddenRequest(t *testing.T) {
207	term := &alertTestTerminal{}
208	disabled := false
209	client := &interactiveTitleClient{title: "should not be used"}
210	ag := core.NewAgent(client, "test-model", "", nil)
211	i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term, TerminalTitleEnabled: &disabled})
212	i.markInteractiveStarted()
213
214	i.startTurn(context.Background(), "do the work")
215	waitInteractiveIdle(t, i)
216
217	if got := client.requestCount(); got != 1 {
218		t.Fatalf("provider request count = %d, want only the main request", got)
219	}
220	if got := term.String(); got != "" {
221		t.Fatalf("disabled title output = %q, want empty", got)
222	}
223}
224
225func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) {
226	term := &alertTestTerminal{}
227	client := &interactiveTitleClient{title: "new branch title"}
228	ag := core.NewAgent(client, "test-model", "", nil)
229	ag.SetMessages([]provider.Message{{
230		Role:    provider.RoleUser,
231		Content: []provider.Content{provider.TextBlock{Text: "copied parent prompt"}},
232	}})
233	i := NewInteractive(InteractiveConfig{
234		Agent:                      ag,
235		Terminal:                   term,
236		InitialSessionTitlePending: true,
237	})
238	i.markInteractiveStarted()
239
240	i.Submit("new branch work")
241	waitInteractiveIdle(t, i)
242	// The hidden title request is deliberately asynchronous, so wait for its
243	// visible result rather than assuming it finishes before the main turn.
244	waitInteractiveTitle(t, i, "new branch title")
245	if got := client.requestCount(); got != 2 {
246		t.Fatalf("provider request count = %d, want main + hidden title", got)
247	}
248	i.mu.Lock()
249	seen := i.titleRealPromptSeen
250	i.mu.Unlock()
251	if !seen {
252		t.Fatal("fresh branch did not record its first real prompt")
253	}
254}
255
256func TestDisablingTerminalTitlesRestoresNeutralTitle(t *testing.T) {
257	term := &alertTestTerminal{}
258	enabled := true
259	i := NewInteractive(InteractiveConfig{
260		Terminal:             term,
261		TerminalTitleEnabled: &enabled,
262		InitialSessionTitle:  "Existing work",
263	})
264	i.markInteractiveStarted()
265	i.applySettingToggle("terminal_title_enabled", false)
266
267	got := term.String()
268	if !strings.Contains(got, "\x1b]0;zut: Existing work\x07") || !strings.Contains(got, "\x1b]0;zut\x07") {
269		t.Fatalf("terminal title output = %q, want existing and neutral title sequences", got)
270	}
271}
272
273func TestLoadedSessionTitleIsRestoredWithoutGeneration(t *testing.T) {
274	term := &alertTestTerminal{}
275	ag := core.NewAgent(nil, "test-model", "", nil)
276	ag.SetMessages([]provider.Message{{
277		Role:    provider.RoleUser,
278		Content: []provider.Content{provider.TextBlock{Text: "existing prompt"}},
279	}})
280	i := NewInteractive(InteractiveConfig{
281		Agent:               ag,
282		Terminal:            term,
283		InitialSessionTitle: "Existing work",
284	})
285	i.markInteractiveStarted()
286
287	if got, want := term.String(), "\x1b]0;zut: Existing work\x07"; got != want {
288		t.Fatalf("restored terminal title = %q, want %q", got, want)
289	}
290	i.mu.Lock()
291	seen := i.titleRealPromptSeen
292	started := i.titleGenerationStarted
293	i.mu.Unlock()
294	if !seen || !started {
295		t.Fatal("loaded session was eligible for a new title")
296	}
297}

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 477

↑↓ moveenter openesc close