pi-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +51 -0
- package/extensions/claude-rules.ts +136 -0
- package/extensions/commands.ts +45 -0
- package/extensions/context-imports.ts +118 -0
- package/extensions/git-checkpoint.ts +199 -0
- package/extensions/hooks.ts +178 -0
- package/extensions/mcp.ts +268 -0
- package/extensions/memory.ts +127 -0
- package/extensions/notify.ts +52 -0
- package/extensions/output-styles.ts +159 -0
- package/extensions/plan-mode/README.md +65 -0
- package/extensions/plan-mode/index.ts +352 -0
- package/extensions/plan-mode/utils.ts +175 -0
- package/extensions/question.ts +258 -0
- package/extensions/skills.ts +41 -0
- package/extensions/status-line.ts +59 -0
- package/extensions/subagent/README.md +172 -0
- package/extensions/subagent/agents/planner.md +37 -0
- package/extensions/subagent/agents/reviewer.md +35 -0
- package/extensions/subagent/agents/scout.md +50 -0
- package/extensions/subagent/agents/worker.md +24 -0
- package/extensions/subagent/agents.ts +147 -0
- package/extensions/subagent/background.ts +89 -0
- package/extensions/subagent/index.ts +953 -0
- package/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/extensions/subagent/prompts/implement.md +10 -0
- package/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/extensions/todo.ts +504 -0
- package/extensions/web.ts +195 -0
- package/package.json +59 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Worker implements, reviewer reviews, worker applies feedback
|
|
3
|
+
---
|
|
4
|
+
Use the subagent tool with the chain parameter to execute this workflow:
|
|
5
|
+
|
|
6
|
+
1. First, use the "worker" agent to implement: $@
|
|
7
|
+
2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder)
|
|
8
|
+
3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder)
|
|
9
|
+
|
|
10
|
+
Execute this as a chain, passing output between steps via {previous}.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Full implementation workflow - scout gathers context, planner creates plan, worker implements
|
|
3
|
+
---
|
|
4
|
+
Use the subagent tool with the chain parameter to execute this workflow:
|
|
5
|
+
|
|
6
|
+
1. First, use the "scout" agent to find all code relevant to: $@
|
|
7
|
+
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
|
|
8
|
+
3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder)
|
|
9
|
+
|
|
10
|
+
Execute this as a chain, passing output between steps via {previous}.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Scout gathers context, planner creates implementation plan (no implementation)
|
|
3
|
+
---
|
|
4
|
+
Use the subagent tool with the chain parameter to execute this workflow:
|
|
5
|
+
|
|
6
|
+
1. First, use the "scout" agent to find all code relevant to: $@
|
|
7
|
+
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
|
|
8
|
+
|
|
9
|
+
Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan.
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Extension - Claude Code style todo tracking with a live overlay
|
|
3
|
+
*
|
|
4
|
+
* This extension:
|
|
5
|
+
* - Registers a `todo` tool for the LLM (add, start, complete, delete, clear, list)
|
|
6
|
+
* - Registers a `/todos` command for users to view the list
|
|
7
|
+
* - Shows a persistent widget above the editor with live todo status
|
|
8
|
+
*
|
|
9
|
+
* Todos move through a status machine: pending -> in_progress -> completed.
|
|
10
|
+
* Exactly one todo is in_progress at a time; `start` moves any other
|
|
11
|
+
* in_progress todo back to pending. An optional activeForm label (present
|
|
12
|
+
* continuous, e.g. "Writing tests") is shown while a todo is in_progress.
|
|
13
|
+
*
|
|
14
|
+
* State is stored in tool result details (not external files), which allows
|
|
15
|
+
* proper branching - when you branch, the todo state is automatically
|
|
16
|
+
* correct for that point in history. The same replay runs on session_start,
|
|
17
|
+
* session_tree, and session_compact, so the list survives compaction.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { StringEnum } from '@earendil-works/pi-ai'
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
22
|
+
import { matchesKey, Text, type TUI, truncateToWidth } from '@earendil-works/pi-tui'
|
|
23
|
+
import { type Static, Type } from 'typebox'
|
|
24
|
+
|
|
25
|
+
type TodoStatus = 'pending' | 'in_progress' | 'completed'
|
|
26
|
+
type TodoAction = 'add' | 'start' | 'complete' | 'delete' | 'clear' | 'list'
|
|
27
|
+
|
|
28
|
+
export interface Todo {
|
|
29
|
+
id: number
|
|
30
|
+
text: string
|
|
31
|
+
status: TodoStatus
|
|
32
|
+
activeForm?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface TodoDetails {
|
|
36
|
+
action: TodoAction
|
|
37
|
+
todos: Todo[]
|
|
38
|
+
nextId: number
|
|
39
|
+
error?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pre-status-machine persisted shape (`done` boolean) still replays. */
|
|
43
|
+
export interface LegacyTodo {
|
|
44
|
+
id: number
|
|
45
|
+
text: string
|
|
46
|
+
status?: TodoStatus
|
|
47
|
+
activeForm?: string
|
|
48
|
+
done?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TodoParams = Type.Object({
|
|
52
|
+
action: StringEnum(['add', 'start', 'complete', 'delete', 'clear', 'list'] as const),
|
|
53
|
+
text: Type.Optional(Type.String({ description: 'Todo text (for add)' })),
|
|
54
|
+
activeForm: Type.Optional(
|
|
55
|
+
Type.String({
|
|
56
|
+
description: "Present-continuous label shown while in_progress, e.g. 'Writing tests' (for add/start)",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
id: Type.Optional(Type.Number({ description: 'Todo ID (for start, complete, delete)' })),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
type TodoParamsType = Static<typeof TodoParams>
|
|
63
|
+
|
|
64
|
+
const TOOL_NAME = 'todo'
|
|
65
|
+
const WIDGET_KEY = 'todos'
|
|
66
|
+
// Content budget: heading + todo rows + optional "+N more" summary. The
|
|
67
|
+
// rendered widget is one line taller (trailing spacer below the panel).
|
|
68
|
+
const MAX_WIDGET_LINES = 12
|
|
69
|
+
|
|
70
|
+
export const normalizeTodo = (raw: LegacyTodo): Todo => {
|
|
71
|
+
if (raw.status) return { id: raw.id, text: raw.text, status: raw.status, activeForm: raw.activeForm }
|
|
72
|
+
return { id: raw.id, text: raw.text, status: raw.done ? 'completed' : 'pending' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const statusGlyph = (status: TodoStatus, theme: Theme): string => {
|
|
76
|
+
if (status === 'completed') return theme.fg('success', '✓')
|
|
77
|
+
if (status === 'in_progress') return theme.fg('accent', '◐')
|
|
78
|
+
return theme.fg('dim', '○')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const listMark = (status: TodoStatus): string => {
|
|
82
|
+
if (status === 'completed') return '[x]'
|
|
83
|
+
if (status === 'in_progress') return '[>]'
|
|
84
|
+
return '[ ]'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const overlayLabel = (todo: Todo, theme: Theme): string => {
|
|
88
|
+
if (todo.status === 'completed') return theme.fg('dim', todo.text)
|
|
89
|
+
if (todo.status === 'in_progress') return theme.fg('text', todo.activeForm ?? todo.text)
|
|
90
|
+
return theme.fg('muted', todo.text)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface OverlayLayout {
|
|
94
|
+
visible: Todo[]
|
|
95
|
+
hiddenCompleted: number
|
|
96
|
+
truncatedTail: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fit todos into `budget` rows: drop completed first, then truncate the
|
|
101
|
+
* non-completed tail. On overflow one row is reserved for the "+N more"
|
|
102
|
+
* summary the caller appends.
|
|
103
|
+
*/
|
|
104
|
+
export const layoutOverlay = (todos: Todo[], budget: number): OverlayLayout => {
|
|
105
|
+
if (todos.length <= budget) {
|
|
106
|
+
return { visible: todos, hiddenCompleted: 0, truncatedTail: 0 }
|
|
107
|
+
}
|
|
108
|
+
const innerBudget = budget - 1
|
|
109
|
+
const nonCompleted = todos.filter((t) => t.status !== 'completed')
|
|
110
|
+
const totalCompleted = todos.length - nonCompleted.length
|
|
111
|
+
if (nonCompleted.length <= innerBudget) {
|
|
112
|
+
const kept = new Set<Todo>(nonCompleted)
|
|
113
|
+
for (const t of todos) {
|
|
114
|
+
if (kept.size >= innerBudget) break
|
|
115
|
+
if (t.status === 'completed') kept.add(t)
|
|
116
|
+
}
|
|
117
|
+
const visible = todos.filter((t) => kept.has(t))
|
|
118
|
+
const shownCompleted = visible.filter((t) => t.status === 'completed').length
|
|
119
|
+
return { visible, hiddenCompleted: totalCompleted - shownCompleted, truncatedTail: 0 }
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
visible: nonCompleted.slice(0, innerBudget),
|
|
123
|
+
hiddenCompleted: totalCompleted,
|
|
124
|
+
truncatedTail: nonCompleted.length - innerBudget,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persistent widget above the editor. Factory-form setWidget registration,
|
|
130
|
+
* register-once + requestRender() refresh, auto-hide when the list is empty.
|
|
131
|
+
* Reads live state via getTodos() at render time.
|
|
132
|
+
*/
|
|
133
|
+
class TodoOverlay {
|
|
134
|
+
private uiCtx?: ExtensionUIContext
|
|
135
|
+
private widgetRegistered = false
|
|
136
|
+
private tui?: TUI
|
|
137
|
+
private getTodos: () => Todo[]
|
|
138
|
+
|
|
139
|
+
constructor(getTodos: () => Todo[]) {
|
|
140
|
+
this.getTodos = getTodos
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setUICtx(ctx: ExtensionUIContext): void {
|
|
144
|
+
// Identity-compare so repeat session_start handlers are idempotent;
|
|
145
|
+
// on identity change (/reload) invalidate so update() re-registers.
|
|
146
|
+
if (ctx !== this.uiCtx) {
|
|
147
|
+
this.uiCtx = ctx
|
|
148
|
+
this.widgetRegistered = false
|
|
149
|
+
this.tui = undefined
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
update(): void {
|
|
154
|
+
if (!this.uiCtx) return
|
|
155
|
+
if (this.getTodos().length === 0) {
|
|
156
|
+
this.hide()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (this.widgetRegistered) {
|
|
160
|
+
this.tui?.requestRender()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
this.uiCtx.setWidget(
|
|
164
|
+
WIDGET_KEY,
|
|
165
|
+
(tui, theme) => {
|
|
166
|
+
this.tui = tui
|
|
167
|
+
return {
|
|
168
|
+
render: (width: number) => this.renderWidget(theme, width),
|
|
169
|
+
invalidate: () => {
|
|
170
|
+
this.widgetRegistered = false
|
|
171
|
+
this.tui = undefined
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
{ placement: 'aboveEditor' },
|
|
176
|
+
)
|
|
177
|
+
this.widgetRegistered = true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
dispose(): void {
|
|
181
|
+
this.hide()
|
|
182
|
+
this.uiCtx = undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private hide(): void {
|
|
186
|
+
if (!this.widgetRegistered) return
|
|
187
|
+
this.uiCtx?.setWidget(WIDGET_KEY, undefined)
|
|
188
|
+
this.widgetRegistered = false
|
|
189
|
+
this.tui = undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private renderWidget(theme: Theme, width: number): string[] {
|
|
193
|
+
const todos = this.getTodos()
|
|
194
|
+
if (todos.length === 0) return []
|
|
195
|
+
const truncate = (line: string): string => truncateToWidth(line, width, '…')
|
|
196
|
+
|
|
197
|
+
const completed = todos.filter((t) => t.status === 'completed').length
|
|
198
|
+
const hasActive = completed < todos.length
|
|
199
|
+
const headingColor = hasActive ? 'accent' : 'dim'
|
|
200
|
+
const headingIcon = hasActive ? '●' : '○'
|
|
201
|
+
const lines = [truncate(theme.fg(headingColor, `${headingIcon} Todos (${completed}/${todos.length})`))]
|
|
202
|
+
|
|
203
|
+
const layout = layoutOverlay(todos, MAX_WIDGET_LINES - 1)
|
|
204
|
+
for (const todo of layout.visible) {
|
|
205
|
+
lines.push(truncate(`${theme.fg('dim', '├─')} ${statusGlyph(todo.status, theme)} ${overlayLabel(todo, theme)}`))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const totalHidden = layout.hiddenCompleted + layout.truncatedTail
|
|
209
|
+
if (totalHidden === 0) {
|
|
210
|
+
const last = lines.length - 1
|
|
211
|
+
lines[last] = lines[last].replace('├─', '└─')
|
|
212
|
+
} else {
|
|
213
|
+
const parts: string[] = []
|
|
214
|
+
if (layout.hiddenCompleted > 0) parts.push(`${layout.hiddenCompleted} completed`)
|
|
215
|
+
if (layout.truncatedTail > 0) parts.push(`${layout.truncatedTail} pending`)
|
|
216
|
+
lines.push(truncate(theme.fg('dim', `└─ +${totalHidden} more (${parts.join(', ')})`)))
|
|
217
|
+
}
|
|
218
|
+
// Trailing spacer so the panel isn't flush against the editor box.
|
|
219
|
+
lines.push('')
|
|
220
|
+
return lines
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* UI component for the /todos command
|
|
226
|
+
*/
|
|
227
|
+
class TodoListComponent {
|
|
228
|
+
private todos: Todo[]
|
|
229
|
+
private theme: Theme
|
|
230
|
+
private onClose: () => void
|
|
231
|
+
private cachedWidth?: number
|
|
232
|
+
private cachedLines?: string[]
|
|
233
|
+
|
|
234
|
+
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
|
|
235
|
+
this.todos = todos
|
|
236
|
+
this.theme = theme
|
|
237
|
+
this.onClose = onClose
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handleInput(data: string): void {
|
|
241
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
|
|
242
|
+
this.onClose()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
render(width: number): string[] {
|
|
247
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
248
|
+
return this.cachedLines
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lines: string[] = []
|
|
252
|
+
const th = this.theme
|
|
253
|
+
|
|
254
|
+
lines.push('')
|
|
255
|
+
const title = th.fg('accent', ' Todos ')
|
|
256
|
+
const headerLine = th.fg('borderMuted', '─'.repeat(3)) + title + th.fg('borderMuted', '─'.repeat(Math.max(0, width - 10)))
|
|
257
|
+
lines.push(truncateToWidth(headerLine, width))
|
|
258
|
+
lines.push('')
|
|
259
|
+
|
|
260
|
+
if (this.todos.length === 0) {
|
|
261
|
+
lines.push(truncateToWidth(` ${th.fg('dim', 'No todos yet. Ask the agent to add some!')}`, width))
|
|
262
|
+
} else {
|
|
263
|
+
const completed = this.todos.filter((t) => t.status === 'completed').length
|
|
264
|
+
lines.push(truncateToWidth(` ${th.fg('muted', `${completed}/${this.todos.length} completed`)}`, width))
|
|
265
|
+
lines.push('')
|
|
266
|
+
|
|
267
|
+
for (const todo of this.todos) {
|
|
268
|
+
const glyph = statusGlyph(todo.status, th)
|
|
269
|
+
const id = th.fg('accent', `#${todo.id}`)
|
|
270
|
+
const text = todo.status === 'completed' ? th.fg('dim', todo.text) : th.fg('text', todo.text)
|
|
271
|
+
let line = ` ${glyph} ${id} ${text}`
|
|
272
|
+
if (todo.status === 'in_progress' && todo.activeForm) {
|
|
273
|
+
line += ` ${th.fg('dim', `(${todo.activeForm})`)}`
|
|
274
|
+
}
|
|
275
|
+
lines.push(truncateToWidth(line, width))
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
lines.push('')
|
|
280
|
+
lines.push(truncateToWidth(` ${th.fg('dim', 'Press Escape to close')}`, width))
|
|
281
|
+
lines.push('')
|
|
282
|
+
|
|
283
|
+
this.cachedWidth = width
|
|
284
|
+
this.cachedLines = lines
|
|
285
|
+
return lines
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
invalidate(): void {
|
|
289
|
+
this.cachedWidth = undefined
|
|
290
|
+
this.cachedLines = undefined
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// pi-core's ExtensionRunner throws this exact phrase from an invalidated ctx
|
|
295
|
+
// proxy after session replacement/reload. Match the stable substring so
|
|
296
|
+
// genuine replay bugs still propagate instead of being silently swallowed.
|
|
297
|
+
const isStaleCtxError = (e: unknown): boolean => /stale after session replacement/.test(String(e))
|
|
298
|
+
|
|
299
|
+
export default function (pi: ExtensionAPI) {
|
|
300
|
+
// In-memory state (reconstructed from session on load)
|
|
301
|
+
let todos: Todo[] = []
|
|
302
|
+
let nextId = 1
|
|
303
|
+
const overlay = new TodoOverlay(() => todos)
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Reconstruct state from session entries.
|
|
307
|
+
* Scans tool results for this tool and applies them in order. State is
|
|
308
|
+
* committed only after a full scan, so a throw (stale ctx) keeps the
|
|
309
|
+
* current state intact.
|
|
310
|
+
*/
|
|
311
|
+
const reconstructState = (ctx: ExtensionContext) => {
|
|
312
|
+
let replayTodos: Todo[] = []
|
|
313
|
+
let replayNextId = 1
|
|
314
|
+
|
|
315
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
316
|
+
if (entry.type !== 'message') continue
|
|
317
|
+
const msg = entry.message
|
|
318
|
+
if (msg.role !== 'toolResult' || msg.toolName !== TOOL_NAME) continue
|
|
319
|
+
|
|
320
|
+
const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos: LegacyTodo[] }) | undefined
|
|
321
|
+
if (details) {
|
|
322
|
+
replayTodos = details.todos.map(normalizeTodo)
|
|
323
|
+
replayNextId = details.nextId
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
todos = replayTodos
|
|
328
|
+
nextId = replayNextId
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Replay for session_tree/session_compact. Auto-compaction races session
|
|
333
|
+
* disposal: pi-core can emit these with an already-invalidated ctx proxy
|
|
334
|
+
* whose getters throw the stale error. The replacement session's
|
|
335
|
+
* session_start replays state, so keep current state on a stale ctx.
|
|
336
|
+
*/
|
|
337
|
+
const replayAndRefresh = (ctx: ExtensionContext) => {
|
|
338
|
+
try {
|
|
339
|
+
reconstructState(ctx)
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (!isStaleCtxError(e)) throw e
|
|
342
|
+
}
|
|
343
|
+
overlay.update()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
347
|
+
reconstructState(ctx)
|
|
348
|
+
if (ctx.hasUI) overlay.setUICtx(ctx.ui)
|
|
349
|
+
overlay.update()
|
|
350
|
+
})
|
|
351
|
+
pi.on('session_tree', async (_event, ctx) => replayAndRefresh(ctx))
|
|
352
|
+
pi.on('session_compact', async (_event, ctx) => replayAndRefresh(ctx))
|
|
353
|
+
pi.on('session_shutdown', async () => overlay.dispose())
|
|
354
|
+
|
|
355
|
+
// Reads live state at render time; never replay the branch here (the
|
|
356
|
+
// branch is stale until message_end runs after tool_execution_end).
|
|
357
|
+
pi.on('tool_execution_end', async (event) => {
|
|
358
|
+
if (event.toolName !== TOOL_NAME || event.isError) return
|
|
359
|
+
overlay.update()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
const toolMessage = (text: string, details: TodoDetails) => ({
|
|
363
|
+
content: [{ type: 'text' as const, text }],
|
|
364
|
+
details,
|
|
365
|
+
})
|
|
366
|
+
const ok = (action: TodoAction, text: string) => toolMessage(text, { action, todos: [...todos], nextId })
|
|
367
|
+
const fail = (action: TodoAction, error: string) => toolMessage(`Error: ${error}`, { action, todos: [...todos], nextId, error })
|
|
368
|
+
|
|
369
|
+
const handleAdd = (params: TodoParamsType) => {
|
|
370
|
+
if (!params.text) return fail('add', 'text required for add')
|
|
371
|
+
const newTodo: Todo = { id: nextId++, text: params.text, status: 'pending', activeForm: params.activeForm }
|
|
372
|
+
todos.push(newTodo)
|
|
373
|
+
return ok('add', `Added todo #${newTodo.id}: ${newTodo.text}`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const handleStart = (params: TodoParamsType) => {
|
|
377
|
+
if (params.id === undefined) return fail('start', 'id required for start')
|
|
378
|
+
const todo = todos.find((t) => t.id === params.id)
|
|
379
|
+
if (!todo) return fail('start', `#${params.id} not found`)
|
|
380
|
+
const demoted = todos.filter((t) => t.status === 'in_progress' && t.id !== todo.id)
|
|
381
|
+
for (const other of demoted) other.status = 'pending'
|
|
382
|
+
todo.status = 'in_progress'
|
|
383
|
+
if (params.activeForm) todo.activeForm = params.activeForm
|
|
384
|
+
let text = `Started #${todo.id}: ${todo.text}`
|
|
385
|
+
if (demoted.length > 0) text += ` (moved ${demoted.map((t) => `#${t.id}`).join(', ')} back to pending)`
|
|
386
|
+
return ok('start', text)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const handleComplete = (params: TodoParamsType) => {
|
|
390
|
+
if (params.id === undefined) return fail('complete', 'id required for complete')
|
|
391
|
+
const todo = todos.find((t) => t.id === params.id)
|
|
392
|
+
if (!todo) return fail('complete', `#${params.id} not found`)
|
|
393
|
+
todo.status = 'completed'
|
|
394
|
+
return ok('complete', `Completed #${todo.id}: ${todo.text}`)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const handleDelete = (params: TodoParamsType) => {
|
|
398
|
+
if (params.id === undefined) return fail('delete', 'id required for delete')
|
|
399
|
+
const index = todos.findIndex((t) => t.id === params.id)
|
|
400
|
+
if (index === -1) return fail('delete', `#${params.id} not found`)
|
|
401
|
+
const [removed] = todos.splice(index, 1)
|
|
402
|
+
return ok('delete', `Deleted #${removed.id}: ${removed.text}`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const handleClear = () => {
|
|
406
|
+
const count = todos.length
|
|
407
|
+
todos = []
|
|
408
|
+
nextId = 1
|
|
409
|
+
return ok('clear', `Cleared ${count} todos`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const handleList = () => ok('list', todos.length ? todos.map((t) => `${listMark(t.status)} #${t.id}: ${t.text}`).join('\n') : 'No todos')
|
|
413
|
+
|
|
414
|
+
// Register the todo tool for the LLM
|
|
415
|
+
pi.registerTool({
|
|
416
|
+
name: TOOL_NAME,
|
|
417
|
+
label: 'Todo',
|
|
418
|
+
description:
|
|
419
|
+
'Manage a todo list for tracking multi-step progress. Actions: add (text, optional activeForm), start (id, mark in_progress), complete (id), delete (id), clear, list. Status machine: pending -> in_progress -> completed. Exactly one todo is in_progress at a time; start moves any other in_progress todo back to pending.',
|
|
420
|
+
promptSnippet: 'Manage a todo list to plan and track multi-step work',
|
|
421
|
+
promptGuidelines: [
|
|
422
|
+
'Use the todo tool to create todos for any multi-step work (3+ steps) or when the user gives you a list of tasks. Skip it for single trivial tasks and purely conversational requests.',
|
|
423
|
+
"Keep exactly one todo in_progress at a time: mark a todo in_progress (todo action start) BEFORE beginning work on it, passing activeForm as a present-continuous label (e.g. 'Writing tests').",
|
|
424
|
+
'Mark a todo completed (todo action complete) IMMEDIATELY after finishing it; never batch completions or leave finished work in_progress.',
|
|
425
|
+
],
|
|
426
|
+
parameters: TodoParams,
|
|
427
|
+
|
|
428
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
429
|
+
switch (params.action) {
|
|
430
|
+
case 'add':
|
|
431
|
+
return handleAdd(params)
|
|
432
|
+
case 'start':
|
|
433
|
+
return handleStart(params)
|
|
434
|
+
case 'complete':
|
|
435
|
+
return handleComplete(params)
|
|
436
|
+
case 'delete':
|
|
437
|
+
return handleDelete(params)
|
|
438
|
+
case 'clear':
|
|
439
|
+
return handleClear()
|
|
440
|
+
case 'list':
|
|
441
|
+
return handleList()
|
|
442
|
+
default:
|
|
443
|
+
return fail('list', `unknown action: ${params.action}`)
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
renderCall(args, theme, _context) {
|
|
448
|
+
let text = theme.fg('toolTitle', theme.bold('todo ')) + theme.fg('muted', args.action)
|
|
449
|
+
if (args.id !== undefined) text += ` ${theme.fg('accent', `#${args.id}`)}`
|
|
450
|
+
if (args.text) text += ` ${theme.fg('dim', `"${args.text}"`)}`
|
|
451
|
+
if (args.activeForm) text += ` ${theme.fg('dim', `(${args.activeForm})`)}`
|
|
452
|
+
return new Text(text, 0, 0)
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
renderResult(result, { expanded }, theme, _context) {
|
|
456
|
+
const details = result.details as TodoDetails | undefined
|
|
457
|
+
if (!details) {
|
|
458
|
+
const text = result.content[0]
|
|
459
|
+
return new Text(text?.type === 'text' ? text.text : '', 0, 0)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (details.error) {
|
|
463
|
+
return new Text(theme.fg('error', `Error: ${details.error}`), 0, 0)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (details.action === 'list') {
|
|
467
|
+
const todoList = details.todos
|
|
468
|
+
if (todoList.length === 0) {
|
|
469
|
+
return new Text(theme.fg('dim', 'No todos'), 0, 0)
|
|
470
|
+
}
|
|
471
|
+
let listText = theme.fg('muted', `${todoList.length} todo(s):`)
|
|
472
|
+
const display = expanded ? todoList : todoList.slice(0, 5)
|
|
473
|
+
for (const t of display) {
|
|
474
|
+
const itemText = t.status === 'completed' ? theme.fg('dim', t.text) : theme.fg('muted', t.text)
|
|
475
|
+
listText += `\n${statusGlyph(t.status, theme)} ${theme.fg('accent', `#${t.id}`)} ${itemText}`
|
|
476
|
+
}
|
|
477
|
+
if (!expanded && todoList.length > 5) {
|
|
478
|
+
listText += `\n${theme.fg('dim', `... ${todoList.length - 5} more`)}`
|
|
479
|
+
}
|
|
480
|
+
return new Text(listText, 0, 0)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const text = result.content[0]
|
|
484
|
+
const msg = text?.type === 'text' ? text.text : ''
|
|
485
|
+
const glyph = details.action === 'start' ? theme.fg('accent', '◐ ') : theme.fg('success', '✓ ')
|
|
486
|
+
return new Text(glyph + theme.fg('muted', msg), 0, 0)
|
|
487
|
+
},
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// Register the /todos command for users
|
|
491
|
+
pi.registerCommand('todos', {
|
|
492
|
+
description: 'Show all todos on the current branch',
|
|
493
|
+
handler: async (_args, ctx) => {
|
|
494
|
+
if (!ctx.hasUI) {
|
|
495
|
+
ctx.ui.notify('/todos requires interactive mode', 'error')
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
500
|
+
return new TodoListComponent(todos, theme, () => done())
|
|
501
|
+
})
|
|
502
|
+
},
|
|
503
|
+
})
|
|
504
|
+
}
|