v0.4.2 83c2c60

main.go RawHistoryFolder

1package main
2
3import (
4	"encoding/json"
5	"fmt"
6	"os"
7	"sync"
8
9	"github.com/bnema/zut/packages/agent/ext"
10)
11
12const (
13	extensionName    = "tasked-phases"
14	extensionVersion = "1.0.0"
15	toolName         = "tasked_phases"
16	panelID          = "tasked-phases"
17)
18
19const toolSchema = `{
20  "type": "object",
21  "additionalProperties": false,
22  "properties": {
23    "action": {
24      "type": "string",
25      "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"],
26      "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan."
27    },
28    "spec": {"type": "string", "description": "Spec text used by set_spec"},
29    "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"},
30    "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"},
31    "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"},
32    "taskId": {"type": "string", "description": "Target task id"},
33    "taskText": {"type": "string", "description": "Task text for add_task or update_task"},
34    "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"},
35    "phases": {
36      "type": "array",
37      "description": "Full plan replacement used by replace_plan",
38      "items": {
39        "type": "object",
40        "additionalProperties": false,
41        "properties": {
42          "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."},
43          "title": {"type": "string", "description": "Phase title"},
44          "goal": {"type": "string", "description": "Optional short goal for the phase"},
45          "tasks": {
46            "type": "array",
47            "description": "Checklist tasks for the phase",
48            "items": {
49              "type": "object",
50              "additionalProperties": false,
51              "properties": {
52                "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."},
53                "text": {"type": "string", "description": "Checklist task text"},
54                "checked": {"type": "boolean", "description": "Whether the task starts checked"}
55              },
56              "required": ["text"]
57            }
58          }
59        },
60        "required": ["title"]
61      }
62    }
63  },
64  "required": ["action"]
65}`
66
67type app struct {
68	ext   *ext.Extension
69	store *stateStore
70
71	panelMu   sync.Mutex
72	panelOpen bool
73}
74
75func newApp(e *ext.Extension) *app {
76	return &app{ext: e, store: newStateStore()}
77}
78
79func main() {
80	e := ext.New(extensionName, extensionVersion)
81	a := newApp(e)
82
83	e.OnHello(func(host ext.HostInfo) {
84		dataDir := host.DataDir
85		if dataDir == "" {
86			dataDir = host.ExtensionDir
87		}
88		if err := a.store.load(dataDir, host.CWD); err != nil {
89			e.Logf("load project state: %v", err)
90			a.publishUnavailableChrome()
91			return
92		}
93		if state, err := a.store.snapshot(); err == nil {
94			a.publishChrome(state)
95		} else {
96			e.Logf("snapshot project state: %v", err)
97			a.publishUnavailableChrome()
98		}
99	})
100	restoreSession := func(event ext.Event) {
101		if err := a.store.activateSession(event.State); err != nil {
102			e.Logf("restore session state: %v", err)
103			a.publishUnavailableChrome()
104			return
105		}
106		if state, err := a.store.snapshot(); err == nil {
107			a.publishChrome(state)
108			a.renderPanelIfOpen(state)
109		} else {
110			e.Logf("restore session snapshot: %v", err)
111			a.publishUnavailableChrome()
112		}
113	}
114	e.On("session_opened", restoreSession)
115	e.On("session_switched", restoreSession)
116	e.On("session_forked", restoreSession)
117	e.InterceptTurnStart(func(_ int) ext.TurnStartDecision {
118		state, err := a.store.snapshot()
119		if err != nil {
120			e.Logf("load turn focus: %v", err)
121			return ext.TurnStartDecision{}
122		}
123		return ext.TurnStartDecision{Context: buildTurnContext(state)}
124	})
125
126	e.Tool(toolName,
127		"Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.",
128		json.RawMessage(toolSchema), a.handleTool)
129	e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
130	e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
131
132	if err := e.Run(); err != nil {
133		fmt.Fprintln(os.Stderr, err)
134		os.Exit(1)
135	}
136}
137
138func (a *app) handleCommand(_ string) ext.Response {
139	state, err := a.store.snapshot()
140	if err != nil {
141		a.publishUnavailableChrome()
142		return ext.Errorf("load tasked phases: %v", err)
143	}
144	a.publishChrome(state)
145	a.panelMu.Lock()
146	a.panelOpen = true
147	a.panelMu.Unlock()
148	return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
149}
150
151func (a *app) handlePanelKey(key, _ string) {
152	if key == "esc" {
153		a.ext.ClosePanel(panelID)
154		a.setPanelOpen(false)
155	}
156}
157
158func (a *app) handlePanelClose() { a.setPanelOpen(false) }
159
160func (a *app) setPanelOpen(open bool) {
161	a.panelMu.Lock()
162	a.panelOpen = open
163	a.panelMu.Unlock()
164}
165
166func (a *app) renderPanelIfOpen(state PlanState) {
167	a.panelMu.Lock()
168	open := a.panelOpen
169	a.panelMu.Unlock()
170	if open {
171		a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
172	}
173}
174
175func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
176	var params toolArgs
177	if err := json.Unmarshal(args, &params); err != nil {
178		return ext.TextErrorResult("invalid args: " + err.Error())
179	}
180	if !isKnownAction(params.Action) {
181		return ext.TextErrorResult("unsupported action: " + params.Action)
182	}
183
184	if params.Action == actionGetStatus {
185		state, err := a.store.snapshot()
186		if err != nil {
187			a.publishUnavailableChrome()
188			return toolResult(params.Action, PlanState{}, "", err)
189		}
190		a.publishChrome(state)
191		a.renderPanelIfOpen(state)
192		return toolResult(params.Action, state, "Current phased plan status", nil)
193	}
194
195	state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) {
196		return applyAction(current, params)
197	})
198	// A zero state means transact could not obtain a loaded snapshot. Do not
199	// replace persistent chrome with an invented empty plan; clear it instead.
200	// For action and persistence errors, transact returns the unchanged usable
201	// state, which should remain visible to help the user recover.
202	if err == nil || state.Version != 0 {
203		a.publishChrome(state)
204	} else {
205		a.publishUnavailableChrome()
206	}
207	if err == nil {
208		a.renderPanelIfOpen(state)
209	}
210	return toolResult(params.Action, state, headline, err)
211}
212
213func (a *app) publishUnavailableChrome() {
214	clearChrome(a.ext)
215	a.panelMu.Lock()
216	open := a.panelOpen
217	a.panelMu.Unlock()
218	if open {
219		a.ext.RenderPanel(panelID, "Tasked phases", []string{"Plan state could not be restored for this session."}, "Escape closes this panel")
220	}
221}
222
223type chromeContent struct {
224	summary string
225	lines   []string
226}
227
228const (
229	chromePhaseMaxChars = 32
230	chromeTaskMaxChars  = 64
231)
232
233func buildChrome(state PlanState) (chromeContent, bool) {
234	// A spec-only state is still a plan in progress. Show its compact summary
235	// so the right bar opens as soon as planning starts, without exposing the
236	// spec text in persistent chrome.
237	if !hasStoredPlan(state) || isPlanComplete(state) || isPlanClosed(state) {
238		return chromeContent{}, false
239	}
240	return chromeContent{
241		summary: buildChromeSummary(state),
242		lines:   buildChecklistLines(state),
243	}, true
244}
245
246func buildChromeSummary(state PlanState) string {
247	phaseDone := 0
248	for _, phase := range state.Phases {
249		if isPhaseDone(phase) {
250			phaseDone++
251		}
252	}
253	taskDone, taskTotal := getPlanProgress(state)
254	return fmt.Sprintf("p %d/%d | t %d/%d", phaseDone, len(state.Phases), taskDone, taskTotal)
255}
256
257func chromeLabel(value, fallback string, maxLength int) string {
258	value = truncatePlain(displaySingleLine(value), maxLength)
259	if value == "" {
260		return fallback
261	}
262	return value
263}
264
265func getChromePhase(state PlanState) *Phase {
266	if current := getCurrentPhase(state); current != nil && !isPhaseDone(*current) {
267		return current
268	}
269	for i := range state.Phases {
270		if !isPhaseDone(state.Phases[i]) {
271			return &state.Phases[i]
272		}
273	}
274	if len(state.Phases) == 0 {
275		return nil
276	}
277	return &state.Phases[len(state.Phases)-1]
278}
279
280func getChromeTask(phase Phase) *PhaseTask {
281	for i := range phase.Tasks {
282		if !phase.Tasks[i].Checked {
283			return &phase.Tasks[i]
284		}
285	}
286	return nil
287}
288
289func buildChecklistLines(state PlanState) []string {
290	activePhase := getChromePhase(state)
291	activeTaskID := ""
292	if activePhase != nil {
293		if activeTask := getChromeTask(*activePhase); activeTask != nil {
294			activeTaskID = activeTask.ID
295		}
296	}
297
298	lines := make([]string, 0)
299	for phaseIndex, phase := range state.Phases {
300		if phaseIndex > 0 {
301			lines = append(lines, "")
302		}
303		phaseDone, phaseTotal := getPhaseProgress(phase)
304		phaseMarker := "[ ]"
305		if isPhaseDone(phase) {
306			phaseMarker = "[x]"
307		} else if activePhase != nil && phase.ID == activePhase.ID {
308			phaseMarker = "[>]"
309		}
310		lines = append(lines, fmt.Sprintf("%s %s  %d/%d", phaseMarker, chromeLabel(formatPhaseTitle(phase), "untitled phase", chromePhaseMaxChars), phaseDone, phaseTotal))
311		for _, task := range phase.Tasks {
312			taskMarker := "[ ]"
313			if task.Checked {
314				taskMarker = "[x]"
315			} else if activePhase != nil && phase.ID == activePhase.ID && task.ID == activeTaskID {
316				taskMarker = "[>]"
317			}
318			lines = append(lines, fmt.Sprintf(" %s %s", taskMarker, chromeLabel(formatTaskText(task), "untitled task", chromeTaskMaxChars)))
319		}
320	}
321	if len(lines) == 0 {
322		lines = append(lines, "No phases yet")
323	}
324	return lines
325}
326
327type chromeHost interface {
328	SetStatus(key, text string)
329	ClearStatus(key string)
330	SetWidget(id, position, title string, lines []string)
331	ClearWidget(id string)
332}
333
334func clearChrome(host chromeHost) {
335	host.ClearStatus("progress")
336	host.ClearWidget("plan")
337}
338
339func applyChrome(host chromeHost, state PlanState) {
340	chrome, ok := buildChrome(state)
341	if !ok {
342		clearChrome(host)
343		return
344	}
345	host.SetStatus("progress", chrome.summary)
346	host.SetWidget("plan", ext.WidgetPositionRightBar, chrome.summary, chrome.lines)
347}
348
349func (a *app) publishChrome(state PlanState) {
350	applyChrome(a.ext, state)
351}
352
353func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult {
354	text := buildToolResultText(action, state, headline, err)
355	var result ext.ToolResult
356	if err != nil {
357		result = ext.TextErrorResult(text)
358	} else {
359		result = ext.TextResult(text)
360	}
361	result.Details = ext.JSONDetails(state)
362	return result
363}
364
365func isKnownAction(action string) bool {
366	for _, known := range toolActions {
367		if action == known {
368			return true
369		}
370	}
371	return false
372}

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