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.
@@ -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
+ }