v0.33.0 653c635

goal.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	agenttools "github.com/bnema/zut/packages/agent/tools"
9	"github.com/bnema/zut/packages/core"
10	"github.com/bnema/zut/packages/provider"
11)
12
13const goalContinueMetaKey = "zut_goal_continue"
14
15func (i *Interactive) goalUpdatesAvailable() bool {
16	ag := i.Agent()
17	if ag == nil {
18		return false
19	}
20	_, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName]
21	return ok
22}
23
24func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
25	if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() {
26		return provider.Message{}, false
27	}
28	goal := i.cfg.CurrentGoal()
29	if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
30		return provider.Message{}, false
31	}
32	text := goal.Objective +
33		"\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal. If the current objective has become invalid and a replacement is necessary, you may supersede it only with a concrete objective inside the same mission; never use superseded to avoid difficult work or broaden scope."
34	if goal.ConsecutiveNoProgressTurns > 0 {
35		text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue."
36	}
37	if goal.ID != "" {
38		text += " When superseding this goal, use goal_id " + goal.ID + "."
39	}
40	if goal.MissionID != "" {
41		text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
42	}
43	return provider.Message{
44		Role:    provider.RoleUser,
45		Content: []provider.Content{provider.TextBlock{Text: text}},
46		Meta:    map[string]string{goalContinueMetaKey: "true"},
47	}, true
48}
49
50func (i *Interactive) goalIdleLocked() bool {
51	return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
52}
53
54func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
55	message, ok := i.goalContinuationMessage()
56	if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil {
57		return false
58	}
59	goal := copySessionGoal(i.cfg.CurrentGoal())
60	if goal == nil || i.limitGoalBeforeRun(goal) {
61		return false
62	}
63	i.mu.Lock()
64	if !i.goalIdleLocked() {
65		i.mu.Unlock()
66		return false
67	}
68	i.busy = true
69	i.mu.Unlock()
70	run, err := i.startGoalRun(goal)
71	if err != nil {
72		i.mu.Lock()
73		i.busy = false
74		i.mu.Unlock()
75		i.ReportError(err)
76		return false
77	}
78	if run == nil {
79		i.mu.Lock()
80		i.busy = false
81		i.mu.Unlock()
82		return false
83	}
84	i.startGoalContinuation(parent, message, run)
85	return true
86}
87
88// startReservedGoalContinuation starts a goal turn after another controller
89// has already reserved the interactive turn slot by setting busy. It is used
90// by compact handoffs so a fresh-context continuation receives the same lease,
91// accounting, and no-progress policy as an ordinary idle goal turn.
92func (i *Interactive) startReservedGoalContinuation(parent context.Context) {
93	message, ok := i.goalContinuationMessage()
94	if !ok || i.cfg.CurrentGoal == nil {
95		i.mu.Lock()
96		i.busy = false
97		i.mu.Unlock()
98		i.resetCompactHandoff()
99		i.invalidate()
100		return
101	}
102	goal := copySessionGoal(i.cfg.CurrentGoal())
103	if goal == nil || i.limitGoalBeforeRun(goal) {
104		i.mu.Lock()
105		i.busy = false
106		i.mu.Unlock()
107		i.resetCompactHandoff()
108		i.invalidate()
109		return
110	}
111	run, err := i.startGoalRun(goal)
112	if err != nil || run == nil {
113		i.mu.Lock()
114		i.busy = false
115		i.mu.Unlock()
116		if err != nil {
117			i.ReportError(err)
118		}
119		i.invalidate()
120		return
121	}
122	i.startGoalContinuation(parent, message, run)
123}
124
125func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
126	goalMessage, goalActive := i.goalContinuationMessage()
127	i.mu.Lock()
128	ag := i.agent
129	if ag == nil {
130		i.busy = false
131		pendingIdleWork := i.takePendingIdleWorkLocked()
132		i.mu.Unlock()
133		runPendingIdleWork(pendingIdleWork)
134		i.abandonGoalRun(run)
135		i.invalidate()
136		return
137	}
138	if len(i.queued) > 0 {
139		next := i.queued[0]
140		i.queued = i.queued[1:]
141		i.mu.Unlock()
142		i.abandonGoalRun(run)
143		i.startTurnWithImages(parent, next.Text, next.Images)
144		return
145	}
146	if ag.QueuedMessageCount() > 0 {
147		i.mu.Unlock()
148		i.abandonGoalRun(run)
149		i.startTurnRequest(parent, "", nil, true, false)
150		return
151	}
152	if !goalActive || userMessageText(goalMessage) != userMessageText(message) {
153		i.busy = false
154		pendingIdleWork := i.takePendingIdleWorkLocked()
155		i.mu.Unlock()
156		runPendingIdleWork(pendingIdleWork)
157		// The objective may have been replaced while the prior turn was
158		// finishing. Re-evaluate it now instead of waiting for another event;
159		// the idle gate still gives queued user work priority.
160		i.abandonGoalRun(run)
161		i.requestGoalContinuationIfIdle(parent)
162		i.invalidate()
163		return
164	}
165	i.mu.Unlock()
166	ag.AppendUserContext(userMessageText(message), message.Meta)
167
168	// Persistence callbacks run synchronously from AppendUserContext, so never
169	// hold the TUI mutex across it. Re-check user work that may have arrived
170	// while the hidden continuation was being persisted.
171	i.mu.Lock()
172	if len(i.queued) > 0 {
173		next := i.queued[0]
174		i.queued = i.queued[1:]
175		i.mu.Unlock()
176		i.abandonGoalRun(run)
177		i.startTurnWithImages(parent, next.Text, next.Images)
178		return
179	}
180	i.mu.Unlock()
181	i.startTurnRequest(parent, "", nil, true, false)
182}
183
184func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
185	if goal == nil {
186		return nil
187	}
188	copyGoal := *goal
189	return &copyGoal
190}
191
192func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
193	if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
194		return
195	}
196	goal := copySessionGoal(i.cfg.CurrentGoal())
197	if goal == nil || goal.Status != core.GoalActive {
198		return
199	}
200	goal.Status = status
201	goal.Reason = reason
202	if err := i.cfg.PersistGoal(goal); err != nil {
203		i.ReportError(fmt.Errorf("persist goal: %w", err))
204		return
205	}
206	i.setGoalStatus(goal)
207}
208
209func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
210	if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
211		i.setGoalCommandError("goal: session persistence is unavailable")
212		return
213	}
214
215	current := copySessionGoal(i.cfg.CurrentGoal())
216	if len(parts) == 2 && strings.EqualFold(parts[1], "history") {
217		if i.cfg.CurrentGoalHistory == nil {
218			i.setGoalCommandError("goal: history is unavailable")
219			return
220		}
221		history := i.cfg.CurrentGoalHistory()
222		if len(history) == 0 {
223			i.setGoalCommandStatus("no goal history")
224			return
225		}
226		entries := make([]string, 0, len(history))
227		for _, goal := range history {
228			owner := goal.Owner
229			if owner == "" {
230				owner = core.GoalOwnerUser
231			}
232			entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective)
233			if goal.Reason != "" {
234				entry += " (" + goal.Reason + ")"
235			}
236			entries = append(entries, entry)
237		}
238		i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → "))
239		return
240	}
241	if len(parts) == 1 {
242		if current == nil {
243			i.setGoalCommandStatus("no autonomous goal")
244			return
245		}
246		owner := current.Owner
247		if owner == "" {
248			owner = core.GoalOwnerUser
249		}
250		status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
251		if current.TokenBudget != nil {
252			status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget)
253		} else if current.TokensUsed > 0 {
254			status += fmt.Sprintf(" (%d tokens)", current.TokensUsed)
255		}
256		if current.Reason != "" {
257			status += " (" + current.Reason + ")"
258		}
259		i.setGoalCommandStatus(status)
260		return
261	}
262
263	startIfIdle := false
264	startTitle := false
265	arg := ""
266	if len(parts) == 2 {
267		arg = strings.ToLower(parts[1])
268	}
269	switch arg {
270	case "pause":
271		if current == nil {
272			i.setGoalCommandError("goal: no goal to pause")
273			return
274		}
275		current.Status = core.GoalPaused
276		current.Reason = ""
277	case "resume":
278		if current == nil {
279			i.setGoalCommandError("goal: no goal to resume")
280			return
281		}
282		current.Status = core.GoalActive
283		current.Reason = ""
284		startIfIdle = true
285	case "clear":
286		if err := i.cfg.PersistGoal(nil); err != nil {
287			i.setGoalCommandError("goal: " + err.Error())
288			return
289		}
290		i.setGoalStatus(nil)
291		i.setGoalCommandStatus("autonomous goal cleared")
292		return
293	default:
294		objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
295		if objective == "" {
296			i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history")
297			return
298		}
299		current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
300		if i.cfg.GoalMaxTokenBudget != nil {
301			budget := *i.cfg.GoalMaxTokenBudget
302			if budget > 0 {
303				current.TokenBudget = &budget
304			}
305		}
306		startIfIdle = true
307		startTitle = true
308	}
309
310	if startIfIdle && !i.goalUpdatesAvailable() {
311		i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
312		return
313	}
314	if err := i.cfg.PersistGoal(current); err != nil {
315		i.setGoalCommandError("goal: " + err.Error())
316		return
317	}
318	i.setGoalStatus(current)
319	i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
320	if startTitle {
321		i.maybeStartSessionTitle(ctx, current.Objective)
322	}
323	if startIfIdle {
324		i.requestGoalContinuationIfIdle(ctx)
325	}
326}
327
328// RefreshGoal reloads the status badge after the CLI commits a session switch.
329func (i *Interactive) RefreshGoal() {
330	if i.cfg.CurrentGoal == nil {
331		i.setGoalStatus(nil)
332		return
333	}
334	i.setGoalStatus(i.cfg.CurrentGoal())
335}
336
337func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
338	status := core.GoalStatus("")
339	if goal != nil {
340		status = goal.Status
341	}
342	i.mu.Lock()
343	i.goalStatus = status
344	i.mu.Unlock()
345	i.invalidate()
346}
347
348func (i *Interactive) setGoalCommandStatus(message string) {
349	i.mu.Lock()
350	i.statusOK = message
351	i.statusErr = ""
352	i.mu.Unlock()
353	i.invalidate()
354}
355
356func (i *Interactive) setGoalCommandError(message string) {
357	i.mu.Lock()
358	i.statusOK = ""
359	i.statusErr = message
360	i.mu.Unlock()
361	i.invalidate()
362}

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