@bachi/pi-coder 1.0.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* simple-task — 轻量级任务清单扩展。
|
|
3
|
+
*
|
|
4
|
+
* 功能参照 @thunstack/pi-task-list(会话级、agent 自主管理、无需 plan 模式),
|
|
5
|
+
* 样式参照 @tintinweb/pi-tasks(● 头部统计 + ◻/◼/✔ 符号 + 星形 spinner + 完成项删除线)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么自己写而不是用现成的两个:
|
|
8
|
+
* - @tintinweb/pi-tasks 功能全但重(十几个文件 + 文件锁 store + 一堆配置项),
|
|
9
|
+
* 而且默认 taskScope 会把 `.pi/tasks/tasks-<sessionId>.json` 写进工作仓库,
|
|
10
|
+
* 每个仓库都得加一条 .gitignore。
|
|
11
|
+
* - @thunstack/pi-task-list 够轻,但样式硬编码(☐ ◐ ☑ ⚠ ↷ + 📋 emoji),
|
|
12
|
+
* 想改样式只能 fork 到本地路径包,得自己维护上游同步。
|
|
13
|
+
* 自己写一份就不用背 fork 的维护责任,样式想怎么改都行。
|
|
14
|
+
*
|
|
15
|
+
* 刻意保持轻量:
|
|
16
|
+
* - 三种状态(pending / in_progress / done),无区块、无依赖图、无 note
|
|
17
|
+
* - 三个工具 + 一个命令
|
|
18
|
+
* - 零 npm 依赖:只用宿主提供的 pi / pi-tui(peerDependencies)
|
|
19
|
+
* - **零文件写入**:状态通过 pi.appendEntry() 挂在会话日志上,从不往工作仓库写东西
|
|
20
|
+
* - 颜色全走 theme.fg(),无硬编码 —— 换 pi 主题即换配色
|
|
21
|
+
* - 不强制任务标题字数(pi-task-list 的 3–15 词校验经常误伤短标题)
|
|
22
|
+
*
|
|
23
|
+
* 放在 `~/.pi/agent/extensions/simple-task/`(pi 支持 extensions 下的子目录形式:
|
|
24
|
+
* 目录里放 index.ts 作为入口,见 docs/extensions.md 的放置规则表),
|
|
25
|
+
* 属于自动发现目录,所以支持 `/reload` 热重载。
|
|
26
|
+
*
|
|
27
|
+
* 注意:注释里不能写 `extensions/`+星号+`/index.ts` 这种 glob —— 里面的星号紧跟斜杠
|
|
28
|
+
* 会提前闭合块注释,导致后面全部被当成代码解析。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Type } from "typebox";
|
|
32
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
33
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import {
|
|
35
|
+
cloneState,
|
|
36
|
+
countByStatus,
|
|
37
|
+
describeTasks,
|
|
38
|
+
emptyState,
|
|
39
|
+
isComplete,
|
|
40
|
+
type State,
|
|
41
|
+
type TaskStatus,
|
|
42
|
+
} from "./types.ts";
|
|
43
|
+
import { GLYPHS, buildWidgetLines } from "./widget.ts";
|
|
44
|
+
import { widgetGaps } from "./gap.ts";
|
|
45
|
+
|
|
46
|
+
const WIDGET_KEY = "simple-task";
|
|
47
|
+
const ENTRY_TYPE = "simple-task-state";
|
|
48
|
+
const SPINNER_MS = 150;
|
|
49
|
+
const TOOL_NAMES = ["task_set", "task_update", "task_get"] as const;
|
|
50
|
+
|
|
51
|
+
/** 工具返回里携带结构化状态,供 renderResult 读取计数。 */
|
|
52
|
+
interface ToolDetails {
|
|
53
|
+
action: "set" | "update" | "get";
|
|
54
|
+
state: State;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const STATUS_VALUES = ["pending", "in_progress", "done"] as const;
|
|
58
|
+
|
|
59
|
+
export default function simpleTaskExtension(pi: ExtensionAPI): void {
|
|
60
|
+
let state: State = emptyState();
|
|
61
|
+
let enabled = true;
|
|
62
|
+
let frame = 0;
|
|
63
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
64
|
+
let lastCtx: ExtensionContext | undefined;
|
|
65
|
+
|
|
66
|
+
// ── 持久化 ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 状态挂在会话日志上(pi.appendEntry),不写任何文件。
|
|
70
|
+
* 这是本扩展最重要的设计决定:既不污染工作仓库,又能跨 resume 保留。
|
|
71
|
+
*/
|
|
72
|
+
function persist(): void {
|
|
73
|
+
pi.appendEntry(ENTRY_TYPE, { state: cloneState(state), enabled, updatedAt: Date.now() });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 用 getBranch() 而不是 getEntries():分支导航(回退到旧消息再继续)时必须
|
|
78
|
+
* 跟着当前分支走,否则会把已丢弃分支上的任务状态复活。
|
|
79
|
+
* 取最后一条 —— 每条都是全量快照,最新的即当前状态。
|
|
80
|
+
*/
|
|
81
|
+
function reconstruct(ctx: ExtensionContext): void {
|
|
82
|
+
state = emptyState();
|
|
83
|
+
enabled = true;
|
|
84
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
85
|
+
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
86
|
+
const data = entry.data as { state?: State; enabled?: boolean } | undefined;
|
|
87
|
+
if (data?.state) state = cloneState(data.state);
|
|
88
|
+
if (typeof data?.enabled === "boolean") enabled = data.enabled;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── UI ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* spinner 定时器只在有进行中任务时运行。
|
|
96
|
+
* 帧递增只发生在这里(照抄 pi-tasks 的做法,它的注释解释得很清楚):
|
|
97
|
+
* 若把递增放进 updateUi(),动画速度就会跟着 agent 的繁忙程度变化,
|
|
98
|
+
* 忙的时候飞转、闲的时候卡住。
|
|
99
|
+
*/
|
|
100
|
+
function stopTimer(): void {
|
|
101
|
+
if (timer !== undefined) {
|
|
102
|
+
clearInterval(timer);
|
|
103
|
+
timer = undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ensureTimer(): void {
|
|
108
|
+
const needsAnimation = enabled && state.tasks.some((task) => task.status === "in_progress");
|
|
109
|
+
if (needsAnimation && timer === undefined) {
|
|
110
|
+
timer = setInterval(() => {
|
|
111
|
+
frame += 1;
|
|
112
|
+
// 定时器可能比会话活得久:ctx 失效后访问 ctx.ui 会抛
|
|
113
|
+
// (docs/extensions.md:捕获的 ctx 在会话替换后是 stale,使用即抛),
|
|
114
|
+
// 而抛出发生在 render 之前,render 里的 try/catch 拦不住。
|
|
115
|
+
// 所以这里自己兜住,并且直接停表 —— ctx 没了,动画也没意义了。
|
|
116
|
+
try {
|
|
117
|
+
updateUi(lastCtx);
|
|
118
|
+
} catch {
|
|
119
|
+
stopTimer();
|
|
120
|
+
lastCtx = undefined;
|
|
121
|
+
}
|
|
122
|
+
}, SPINNER_MS);
|
|
123
|
+
} else if (!needsAnimation) {
|
|
124
|
+
stopTimer();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function updateUi(ctx?: ExtensionContext): void {
|
|
129
|
+
if (!ctx) return;
|
|
130
|
+
lastCtx = ctx;
|
|
131
|
+
|
|
132
|
+
const visible = enabled && state.active && state.tasks.length > 0;
|
|
133
|
+
if (!visible) {
|
|
134
|
+
// ctx 可能已 stale(会话结束 / reload / 切换会话),ctx.ui 的 getter 会抛。
|
|
135
|
+
// 吞掉即可 —— 界面本来就要消失,没什么可渲染的。
|
|
136
|
+
try {
|
|
137
|
+
ctx.ui.setStatus(WIDGET_KEY, undefined);
|
|
138
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
139
|
+
} catch {
|
|
140
|
+
/* ctx 已失效 */
|
|
141
|
+
}
|
|
142
|
+
ensureTimer();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { done } = countByStatus(state);
|
|
147
|
+
const total = state.tasks.length;
|
|
148
|
+
const complete = isComplete(state);
|
|
149
|
+
try {
|
|
150
|
+
ctx.ui.setStatus(
|
|
151
|
+
WIDGET_KEY,
|
|
152
|
+
ctx.ui.theme.fg(
|
|
153
|
+
complete ? "success" : "accent",
|
|
154
|
+
`${complete ? GLYPHS.done : GLYPHS.header} ${done}/${total}`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
} catch {
|
|
158
|
+
stopTimer();
|
|
159
|
+
lastCtx = undefined;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 快照 + 每次重新注册:工厂闭包住快照(所以状态不会边渲染边变),
|
|
164
|
+
// 而 frame 是可变变量、在 render 时才读 —— 这就是 spinner 能转的原因。
|
|
165
|
+
const snapshot = cloneState(state);
|
|
166
|
+
try {
|
|
167
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
168
|
+
// 自引用:render 时要用组件自己的身份,去编辑器上方的 widget 容器里定位自己,
|
|
169
|
+
// 从而判断上下是否还挨着别的 widget(pi-subagents 的 async 块、recap 摘要…)
|
|
170
|
+
// 并补一行空行。细节与理由见 gap.ts 的文件头。
|
|
171
|
+
const component = {
|
|
172
|
+
render(width: number): string[] {
|
|
173
|
+
// 渲染异常绝不能逃出去崩掉宿主进程 —— 最坏情况只是这一帧 widget 为空。
|
|
174
|
+
// pi-tasks 有同样的保护,它的注释说明了理由。
|
|
175
|
+
try {
|
|
176
|
+
const lines = buildWidgetLines(snapshot, frame, theme, width);
|
|
177
|
+
if (lines.length === 0) return lines;
|
|
178
|
+
const gaps = widgetGaps(tui, component, width);
|
|
179
|
+
if (gaps.above) lines.unshift("");
|
|
180
|
+
if (gaps.below) lines.push("");
|
|
181
|
+
return lines;
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
invalidate() {},
|
|
187
|
+
};
|
|
188
|
+
return component;
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
stopTimer();
|
|
192
|
+
lastCtx = undefined;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
ensureTimer();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setState(next: State, ctx?: ExtensionContext): void {
|
|
199
|
+
state = cloneState(next);
|
|
200
|
+
persist();
|
|
201
|
+
updateUi(ctx);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function details(action: ToolDetails["action"]): ToolDetails {
|
|
205
|
+
return { action, state: cloneState(state) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertEnabled(): void {
|
|
209
|
+
if (!enabled) throw new Error("Task tracking is disabled for this session. Use /tasks on to re-enable.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── 工具 ──────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
pi.registerTool({
|
|
215
|
+
name: "task_set",
|
|
216
|
+
label: "Task Set",
|
|
217
|
+
description:
|
|
218
|
+
"Create or replace the session task list for multi-step work. Pass an empty array to clear the list.",
|
|
219
|
+
promptSnippet: "Create or replace the session task list.",
|
|
220
|
+
promptGuidelines: [
|
|
221
|
+
"Use task_set for 3+ meaningful steps, multiple files or phases, debugging/research loops, validation passes, or when the user asks for progress tracking.",
|
|
222
|
+
"Do not use task_set for simple Q&A, quick explanations, or one or two obvious actions.",
|
|
223
|
+
"When the objective changes or the current list is obsolete, replace it with task_set instead of appending forever.",
|
|
224
|
+
"Mark progress with task_update rather than writing done markers in prose.",
|
|
225
|
+
],
|
|
226
|
+
parameters: Type.Object({
|
|
227
|
+
tasks: Type.Array(Type.String({ description: "Short actionable task label" }), {
|
|
228
|
+
description: "Ordered task list items; an empty array clears the list",
|
|
229
|
+
}),
|
|
230
|
+
}),
|
|
231
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
232
|
+
assertEnabled();
|
|
233
|
+
const raw = Array.isArray(params?.tasks) ? params.tasks : [];
|
|
234
|
+
// 只去空白与空串,不校验字数 —— pi-task-list 的 3–15 词限制经常误伤短标题。
|
|
235
|
+
const texts = raw.map((item) => String(item).trim()).filter((text) => text.length > 0);
|
|
236
|
+
|
|
237
|
+
if (texts.length === 0) {
|
|
238
|
+
setState(emptyState(), ctx);
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text" as const, text: "Task list cleared." }],
|
|
241
|
+
details: details("set"),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
setState(
|
|
246
|
+
{
|
|
247
|
+
active: true,
|
|
248
|
+
nextId: texts.length + 1,
|
|
249
|
+
tasks: texts.map((text, index) => ({ id: index + 1, text, status: "pending" as TaskStatus })),
|
|
250
|
+
},
|
|
251
|
+
ctx,
|
|
252
|
+
);
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text" as const, text: `Task list set with ${texts.length} task(s).` }],
|
|
255
|
+
details: details("set"),
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
renderCall(args, theme) {
|
|
259
|
+
const count = Array.isArray(args?.tasks) ? args.tasks.length : 0;
|
|
260
|
+
return new Text(
|
|
261
|
+
theme.fg("toolTitle", theme.bold("task_set ")) + theme.fg("muted", `${count} task(s)`),
|
|
262
|
+
0,
|
|
263
|
+
0,
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
renderResult(result, _options, theme) {
|
|
267
|
+
const data = result.details as ToolDetails | undefined;
|
|
268
|
+
const count = data?.state.tasks.length ?? 0;
|
|
269
|
+
const text = count === 0 ? "Task list cleared." : `Task list ready: ${count} task(s)`;
|
|
270
|
+
return new Text(theme.fg("success", "✔ ") + theme.fg("muted", text), 0, 0);
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
pi.registerTool({
|
|
275
|
+
name: "task_update",
|
|
276
|
+
label: "Task Update",
|
|
277
|
+
description: "Update the status of one task in the active task list.",
|
|
278
|
+
promptSnippet: "Update one task's status in the active task list.",
|
|
279
|
+
promptGuidelines: [
|
|
280
|
+
"Use task_update immediately when starting or finishing a tracked task.",
|
|
281
|
+
"Mark a task in_progress before beginning the work, and done right after it finishes.",
|
|
282
|
+
"Prefer task_update over text-only done markers whenever a task list is active.",
|
|
283
|
+
],
|
|
284
|
+
parameters: Type.Object({
|
|
285
|
+
id: Type.Number({ description: "Task ID to update" }),
|
|
286
|
+
status: Type.Union(STATUS_VALUES.map((value) => Type.Literal(value)), {
|
|
287
|
+
description: "New status: pending, in_progress, or done",
|
|
288
|
+
}),
|
|
289
|
+
}),
|
|
290
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
291
|
+
assertEnabled();
|
|
292
|
+
if (!state.active || state.tasks.length === 0) throw new Error("No active task list. Call task_set first.");
|
|
293
|
+
const task = state.tasks.find((item) => item.id === params.id);
|
|
294
|
+
if (!task) throw new Error(`Task #${params.id} not found.`);
|
|
295
|
+
|
|
296
|
+
const next = cloneState(state);
|
|
297
|
+
const target = next.tasks.find((item) => item.id === params.id);
|
|
298
|
+
if (target) target.status = params.status as TaskStatus;
|
|
299
|
+
|
|
300
|
+
setState(next, ctx);
|
|
301
|
+
const completeText = isComplete(state) ? " All tasks are now complete." : "";
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text" as const, text: `Task #${params.id} → ${params.status}.${completeText}` }],
|
|
304
|
+
details: details("update"),
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
renderCall(args, theme) {
|
|
308
|
+
return new Text(
|
|
309
|
+
theme.fg("toolTitle", theme.bold("task_update ")) +
|
|
310
|
+
theme.fg("muted", `#${args?.id ?? "?"} → ${args?.status ?? "?"}`),
|
|
311
|
+
0,
|
|
312
|
+
0,
|
|
313
|
+
);
|
|
314
|
+
},
|
|
315
|
+
renderResult(result, _options, theme) {
|
|
316
|
+
// content 元素可能是 ImageContent,必须先判别再取 text
|
|
317
|
+
const first = result?.content?.[0];
|
|
318
|
+
const text =
|
|
319
|
+
first && "text" in first && typeof first.text === "string" ? first.text : "updated";
|
|
320
|
+
return new Text(theme.fg("success", "✔ ") + theme.fg("muted", text), 0, 0);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
pi.registerTool({
|
|
325
|
+
name: "task_get",
|
|
326
|
+
label: "Task Get",
|
|
327
|
+
description: "Read the active task list with current statuses.",
|
|
328
|
+
promptSnippet: "Read the active task list.",
|
|
329
|
+
parameters: Type.Object({}),
|
|
330
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
331
|
+
assertEnabled();
|
|
332
|
+
const lines = describeTasks(state);
|
|
333
|
+
const text = lines.length === 0 ? "No active task list." : lines.join("\n");
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text" as const, text }],
|
|
336
|
+
details: details("get"),
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
renderCall(_args, theme) {
|
|
340
|
+
return new Text(theme.fg("toolTitle", theme.bold("task_get")), 0, 0);
|
|
341
|
+
},
|
|
342
|
+
renderResult(result, _options, theme) {
|
|
343
|
+
const data = result.details as ToolDetails | undefined;
|
|
344
|
+
const count = data?.state.tasks.length ?? 0;
|
|
345
|
+
const { done } = data ? countByStatus(data.state) : { done: 0 };
|
|
346
|
+
return new Text(
|
|
347
|
+
theme.fg("success", "✔ ") + theme.fg("muted", count === 0 ? "No active task list" : `${done}/${count} done`),
|
|
348
|
+
0,
|
|
349
|
+
0,
|
|
350
|
+
);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── 命令 ──────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
async function handleTasksCommand(args: string, ctx: ExtensionContext): Promise<void> {
|
|
357
|
+
lastCtx = ctx;
|
|
358
|
+
const sub = args.trim().toLowerCase();
|
|
359
|
+
|
|
360
|
+
if (sub === "off" || sub === "on") {
|
|
361
|
+
enabled = sub === "on";
|
|
362
|
+
persist();
|
|
363
|
+
updateUi(ctx);
|
|
364
|
+
ctx.ui.notify(enabled ? "Task tracking enabled." : "Task tracking disabled for this session.", "info");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (sub === "clear") {
|
|
369
|
+
setState(emptyState(), ctx);
|
|
370
|
+
ctx.ui.notify("Task list cleared.", "info");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 默认(含 "status" 与无参数):打印当前清单
|
|
375
|
+
if (!enabled) {
|
|
376
|
+
ctx.ui.notify("Task tracking is disabled. Use /tasks on to re-enable.", "info");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const lines = describeTasks(state);
|
|
380
|
+
if (lines.length === 0) {
|
|
381
|
+
ctx.ui.notify("No active task list.", "info");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const { done, inProgress, pending } = countByStatus(state);
|
|
385
|
+
ctx.ui.notify(
|
|
386
|
+
`${state.tasks.length} task(s) — ${done} done, ${inProgress} in progress, ${pending} open\n${lines.join("\n")}`,
|
|
387
|
+
"info",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
pi.registerCommand("tasks", {
|
|
392
|
+
description: "Show or manage the task list — /tasks [status|clear|on|off]",
|
|
393
|
+
handler: async (args, ctx) => handleTasksCommand(args, ctx),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── 事件 ──────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 会话结束必须立刻停表 —— spinner 定时器会在会话结束后继续 tick,
|
|
400
|
+
* 而那时 lastCtx 已被 pi 作废,读 ctx.ui 会抛。
|
|
401
|
+
* docs/extensions.md 明确要求「注册一个幂等的 session_shutdown 处理器,
|
|
402
|
+
* 关掉你启动的任何会话级资源」,这个 setInterval 就是那种资源。
|
|
403
|
+
*/
|
|
404
|
+
pi.on("session_shutdown", async () => {
|
|
405
|
+
stopTimer();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
409
|
+
reconstruct(ctx);
|
|
410
|
+
updateUi(ctx);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// 分支导航后必须重建:否则会把已丢弃分支上的状态复活
|
|
414
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
415
|
+
reconstruct(ctx);
|
|
416
|
+
updateUi(ctx);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// 压缩时清掉已完成的列表,给下一批工作一个干净的起点
|
|
420
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
421
|
+
if (isComplete(state)) setState(emptyState(), ctx);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 每轮开始前把当前清单塞进上下文(display: false,不进 TUI),
|
|
426
|
+
* 这样模型知道哪些已完成、不必自己猜。
|
|
427
|
+
*/
|
|
428
|
+
pi.on("before_agent_start", async (_event, _ctx) => {
|
|
429
|
+
if (!enabled || !state.active || state.tasks.length === 0) return;
|
|
430
|
+
const lines = describeTasks(state);
|
|
431
|
+
return {
|
|
432
|
+
message: {
|
|
433
|
+
customType: "simple-task-context",
|
|
434
|
+
content: `Current task list (update statuses with task_update as you progress):\n${lines.join("\n")}`,
|
|
435
|
+
display: false,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — simple-task 的状态模型与纯函数工具。
|
|
3
|
+
*
|
|
4
|
+
* 刻意只做最小集:三种状态、无区块、无依赖图、无 note、无 agent 指标。
|
|
5
|
+
* 参照 @thunstack/pi-task-list 的"会话级、agent 自己管理"的功能定位,
|
|
6
|
+
* 但砍掉它那些为 plan-mode / 子 agent 协作服务的复杂度。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type TaskStatus = "pending" | "in_progress" | "done";
|
|
10
|
+
|
|
11
|
+
export interface Task {
|
|
12
|
+
id: number;
|
|
13
|
+
text: string;
|
|
14
|
+
status: TaskStatus;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface State {
|
|
18
|
+
/** 列表是否存在。空列表不算存在 —— widget 与状态栏都不显示。 */
|
|
19
|
+
active: boolean;
|
|
20
|
+
tasks: Task[];
|
|
21
|
+
/** 下一个分配的 id。单调递增且永不复用,避免历史引用错乱。 */
|
|
22
|
+
nextId: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function emptyState(): State {
|
|
26
|
+
return { active: false, tasks: [], nextId: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 深拷贝一层 tasks。状态会被写进会话日志并在多处快照引用,不能共享可变对象。 */
|
|
30
|
+
export function cloneState(state: State): State {
|
|
31
|
+
return { ...state, tasks: state.tasks.map((task) => ({ ...task })) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isComplete(state: State): boolean {
|
|
35
|
+
return state.tasks.length > 0 && state.tasks.every((task) => task.status === "done");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function countByStatus(state: State): { done: number; inProgress: number; pending: number } {
|
|
39
|
+
let done = 0;
|
|
40
|
+
let inProgress = 0;
|
|
41
|
+
let pending = 0;
|
|
42
|
+
for (const task of state.tasks) {
|
|
43
|
+
if (task.status === "done") done += 1;
|
|
44
|
+
else if (task.status === "in_progress") inProgress += 1;
|
|
45
|
+
else pending += 1;
|
|
46
|
+
}
|
|
47
|
+
return { done, inProgress, pending };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 渲染用的纯文本列表(不含 ANSI),给命令与调试用。 */
|
|
51
|
+
export function describeTasks(state: State): string[] {
|
|
52
|
+
return state.tasks.map((task) => `${task.status === "done" ? "[x]" : task.status === "in_progress" ? "[~]" : "[ ]"} #${task.id} ${task.text}`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* widget.ts — 清单渲染,样式参照 @tintinweb/pi-tasks。
|
|
3
|
+
*
|
|
4
|
+
* 照搬的样式要素(pi-tasks `src/ui/task-widget.ts`):
|
|
5
|
+
* - 头部一行:`● N tasks (X done, Y in progress, Z open)`,整体 accent 色
|
|
6
|
+
* - 任务行两空格缩进,格式 ` <符号> <dim 的 #id> <正文>`
|
|
7
|
+
* - 已完成:success 色符号 + dim 且带删除线的正文
|
|
8
|
+
* - 进行中:accent 色星形 spinner 帧 + accent 色正文 + 省略号(表示仍在跑)
|
|
9
|
+
* - 待办:无色符号(◻)+ 普通正文
|
|
10
|
+
* - 溢出:` … and N more`,dim
|
|
11
|
+
* - 整行按终端宽度截断,截断标记用 "..."(三个 ASCII 点)而非 "…" ——
|
|
12
|
+
* 这是 pi-tasks 的 `truncation` 默认值,注释里说明它是 pi-tui 自己的默认。
|
|
13
|
+
*
|
|
14
|
+
* 颜色**全部走 theme.fg()**,一处硬编码颜色都没有 —— 所以跟着 pi 主题走,
|
|
15
|
+
* 换主题就换配色,不需要改这里。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { countByStatus, type State } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
export const GLYPHS = {
|
|
23
|
+
header: "●",
|
|
24
|
+
pending: "◻",
|
|
25
|
+
inProgress: "◼",
|
|
26
|
+
done: "✔",
|
|
27
|
+
overflow: "…",
|
|
28
|
+
truncation: "...",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/** pi-tasks 的星形 spinner 帧序列,原样沿用。 */
|
|
32
|
+
// 刻意去掉了 pi-tasks 原序列末尾的 ✽ —— 它比前面几帧粗重,转起来会突兀。
|
|
33
|
+
export const SPINNER = ["+", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼"] as const;
|
|
34
|
+
|
|
35
|
+
/** pi-tasks 的 DEFAULT_MAX_VISIBLE_TASKS。超出部分折叠成 "… and N more"。 */
|
|
36
|
+
export const MAX_VISIBLE = 10;
|
|
37
|
+
|
|
38
|
+
export function buildWidgetLines(state: State, frame: number, theme: Theme, width: number): string[] {
|
|
39
|
+
const tasks = state.tasks;
|
|
40
|
+
if (tasks.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
const safeWidth = Math.max(1, width);
|
|
43
|
+
const truncate = (line: string): string => truncateToWidth(line, safeWidth, GLYPHS.truncation);
|
|
44
|
+
|
|
45
|
+
const { done, inProgress, pending } = countByStatus(state);
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
if (done > 0) parts.push(`${done} done`);
|
|
48
|
+
if (inProgress > 0) parts.push(`${inProgress} in progress`);
|
|
49
|
+
if (pending > 0) parts.push(`${pending} open`);
|
|
50
|
+
const noun = tasks.length === 1 ? "task" : "tasks";
|
|
51
|
+
const statusText = `${tasks.length} ${noun} (${parts.join(", ")})`;
|
|
52
|
+
|
|
53
|
+
const lines: string[] = [
|
|
54
|
+
truncate(`${theme.fg("accent", GLYPHS.header)} ${theme.fg("accent", statusText)}`),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const visible = tasks.slice(0, MAX_VISIBLE);
|
|
58
|
+
const hidden = tasks.length - visible.length;
|
|
59
|
+
|
|
60
|
+
for (const task of visible) {
|
|
61
|
+
const dimId = theme.fg("dim", `#${task.id}`);
|
|
62
|
+
|
|
63
|
+
if (task.status === "done") {
|
|
64
|
+
// 完成项:success 符号 + dim 删除线正文(id 与正文一起划掉)
|
|
65
|
+
const glyph = theme.fg("success", GLYPHS.done);
|
|
66
|
+
lines.push(truncate(` ${glyph} ${theme.fg("dim", theme.strikethrough(`#${task.id} ${task.text}`))}`));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (task.status === "in_progress") {
|
|
71
|
+
// 进行中:星形帧随 frame 转动,正文 accent + 省略号表示还没结束
|
|
72
|
+
const glyph = theme.fg("accent", SPINNER[frame % SPINNER.length]);
|
|
73
|
+
lines.push(truncate(` ${glyph} ${dimId} ${theme.fg("accent", task.text + GLYPHS.overflow)}`));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
lines.push(truncate(` ${GLYPHS.pending} ${dimId} ${task.text}`));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (hidden > 0) {
|
|
81
|
+
lines.push(truncate(theme.fg("dim", ` ${GLYPHS.overflow} and ${hidden} more`)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|