@zhushanwen/pi-goal 0.2.0 → 0.4.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/README.md +13 -12
- package/package.json +8 -4
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +80 -0
- package/src/__tests__/event-adapter.test.ts +339 -0
- package/src/__tests__/goal-control-adapter.test.ts +303 -0
- package/src/__tests__/index.test.ts +119 -0
- package/src/__tests__/service.test.ts +317 -0
- package/src/__tests__/session.test.ts +220 -0
- package/src/__tests__/stale-checker.test.ts +78 -0
- package/src/__tests__/stubs/pi-sdk.ts +16 -0
- package/src/__tests__/stubs/typebox.ts +14 -0
- package/src/adapters/command-adapter.ts +360 -0
- package/src/adapters/event-adapter.ts +17 -0
- package/src/adapters/event-handlers/agent-end.ts +177 -0
- package/src/adapters/event-handlers/agent-start.ts +17 -0
- package/src/adapters/event-handlers/before-agent-start.ts +115 -0
- package/src/adapters/event-handlers/message-end.ts +37 -0
- package/src/adapters/event-handlers/session-start.ts +24 -0
- package/src/adapters/event-handlers/shared.ts +46 -0
- package/src/adapters/event-handlers/turn-end.ts +37 -0
- package/src/adapters/goal-control-adapter.ts +296 -0
- package/src/adapters/ports.ts +89 -0
- package/src/commands.ts +7 -19
- package/src/constants.ts +1 -3
- package/src/engine/__tests__/budget.test.ts +182 -0
- package/src/engine/__tests__/goal.test.ts +126 -0
- package/src/engine/budget.ts +146 -0
- package/src/engine/goal.ts +62 -0
- package/src/engine/types.ts +80 -0
- package/src/index.ts +110 -333
- package/src/persistence.ts +83 -0
- package/src/ports.ts +68 -0
- package/src/projection/__tests__/prompts.test.ts +245 -0
- package/src/projection/__tests__/widget.test.ts +255 -0
- package/src/projection/prompts.ts +250 -0
- package/src/projection/widget.ts +254 -0
- package/src/service.ts +276 -0
- package/src/session.ts +114 -0
- package/src/action-handlers.ts +0 -341
- package/src/agent-end-handler.ts +0 -304
- package/src/before-agent-start-handler.ts +0 -182
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -280
- package/src/state.ts +0 -250
- package/src/templates.ts +0 -232
- package/src/tool-handler.ts +0 -252
- package/src/widget.ts +0 -186
package/src/tool-handler.ts
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Goal 扩展 — Tool 执行处理器和共享 helpers
|
|
3
|
-
*
|
|
4
|
-
* 从 index.ts 提取的 executeGoalAction 及其依赖的辅助函数、类型和 schema。
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { StringEnum } from "@mariozechner/pi-ai";
|
|
8
|
-
import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { type Static,Type } from "typebox";
|
|
10
|
-
|
|
11
|
-
import { ACTION_HANDLERS } from "./action-handlers.js";
|
|
12
|
-
import {
|
|
13
|
-
MS_PER_SECOND,
|
|
14
|
-
SECONDS_PER_MINUTE,
|
|
15
|
-
} from "./constants";
|
|
16
|
-
import {
|
|
17
|
-
getCompletedCount,
|
|
18
|
-
getElapsedTimeSeconds,
|
|
19
|
-
GOAL_TASK_STATUSES,
|
|
20
|
-
type GoalRuntimeState,
|
|
21
|
-
type GoalTask,
|
|
22
|
-
isTerminalStatus,
|
|
23
|
-
serializeState,
|
|
24
|
-
SUBTASK_STATUSES,
|
|
25
|
-
} from "./state";
|
|
26
|
-
import { renderStatusLine, renderTerminalStatusLine,renderWidgetLines } from "./widget";
|
|
27
|
-
|
|
28
|
-
// ── 常量 ─────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
const ENTRY_TYPE = "goal-state";
|
|
31
|
-
|
|
32
|
-
export const HISTORY_ENTRY_TYPE = "goal-history";
|
|
33
|
-
|
|
34
|
-
// ── Session State Interface ──────────────────────────
|
|
35
|
-
|
|
36
|
-
export interface GoalSession {
|
|
37
|
-
state: GoalRuntimeState | null;
|
|
38
|
-
tasksCompletedAtAgentStart: number;
|
|
39
|
-
hasPendingInjection: boolean;
|
|
40
|
-
/** 防重入标志:handleAgentEnd / handleBeforeAgentStart 等事件处理器入口检查 */
|
|
41
|
-
isProcessing: boolean;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── Stale Context Detection ──────────────────────────
|
|
45
|
-
|
|
46
|
-
const STALE_CONTEXT_PATTERNS = ["aborted", "context canceled", "stale context", "stalecontext", "extension context no longer active"];
|
|
47
|
-
|
|
48
|
-
/** 检查错误是否表示 stale / canceled context(如 session 重建 / compact 后) */
|
|
49
|
-
export function isStaleContextError(error: Error | unknown): boolean {
|
|
50
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
51
|
-
const lower = msg.toLowerCase();
|
|
52
|
-
return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ── Tool Parameter Schema ────────────────────────────
|
|
56
|
-
|
|
57
|
-
export const GoalManagerParams = Type.Object({
|
|
58
|
-
action: StringEnum([
|
|
59
|
-
"create_tasks",
|
|
60
|
-
"add_tasks",
|
|
61
|
-
"update_tasks",
|
|
62
|
-
"list_tasks",
|
|
63
|
-
"complete_goal",
|
|
64
|
-
"cancel_goal",
|
|
65
|
-
"report_blocked",
|
|
66
|
-
"add_subtasks",
|
|
67
|
-
"update_subtasks",
|
|
68
|
-
"delete_subtasks",
|
|
69
|
-
] as const),
|
|
70
|
-
tasks: Type.Optional(Type.Array(Type.String(), { description: "Task descriptions. Each must be a one-line summary (max 60 chars), no newlines or markdown" })),
|
|
71
|
-
updates: Type.Optional(Type.Array(Type.Object({
|
|
72
|
-
taskId: Type.Number(),
|
|
73
|
-
status: StringEnum(GOAL_TASK_STATUSES),
|
|
74
|
-
evidence: Type.Optional(Type.String()),
|
|
75
|
-
}))),
|
|
76
|
-
taskId: Type.Optional(Type.Number({ description: "Task ID (required for subtask operations)" })),
|
|
77
|
-
texts: Type.Optional(Type.Array(Type.String(), { description: "Subtask text list (for add_subtasks)" })),
|
|
78
|
-
subUpdates: Type.Optional(Type.Array(Type.Object({
|
|
79
|
-
subId: Type.Number(),
|
|
80
|
-
status: StringEnum(SUBTASK_STATUSES),
|
|
81
|
-
}))),
|
|
82
|
-
subIds: Type.Optional(Type.Array(Type.Number(), { description: "Subtask ID list (for delete_subtasks)" })),
|
|
83
|
-
evidence: Type.Optional(Type.String({ description: "Evidence for completion (required for complete_goal)" })),
|
|
84
|
-
reason: Type.Optional(Type.String({ description: "Reason for being blocked (required for report_blocked)" })),
|
|
85
|
-
cancelReason: Type.Optional(Type.String({ description: "Why the user wants to cancel (required for cancel_goal)" })),
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// ── Tool Details Types ───────────────────────────────
|
|
89
|
-
|
|
90
|
-
export interface GoalManagerDetails {
|
|
91
|
-
action: string;
|
|
92
|
-
tasks: GoalTask[];
|
|
93
|
-
goalId: string;
|
|
94
|
-
status: string;
|
|
95
|
-
_render?: {
|
|
96
|
-
type: "task-list" | "summary-table" | "progress" | "code-block";
|
|
97
|
-
summary?: string;
|
|
98
|
-
data: unknown;
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ── Module-level Helpers ─────────────────────────────
|
|
103
|
-
|
|
104
|
-
export function isGoalEntry(entry: SessionEntry): entry is CustomEntry<GoalRuntimeState> {
|
|
105
|
-
return entry.type === "custom" && (entry as CustomEntry).customType === ENTRY_TYPE;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function persistGoalState(pi: ExtensionAPI, session: GoalSession, _ctx: ExtensionContext): void {
|
|
109
|
-
if (!session.state) return;
|
|
110
|
-
const now = Date.now();
|
|
111
|
-
if (session.state.timeStartedAt > 0) {
|
|
112
|
-
session.state.timeUsedSeconds += (now - session.state.timeStartedAt) / MS_PER_SECOND;
|
|
113
|
-
session.state.timeStartedAt = now;
|
|
114
|
-
}
|
|
115
|
-
pi.appendEntry(ENTRY_TYPE, serializeState(session.state));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function writeGoalHistoryEntry(pi: ExtensionAPI, session: GoalSession): void {
|
|
119
|
-
const state = session.state;
|
|
120
|
-
if (!state) return;
|
|
121
|
-
pi.appendEntry(HISTORY_ENTRY_TYPE, {
|
|
122
|
-
goalId: state.goalId,
|
|
123
|
-
objective: state.objective,
|
|
124
|
-
status: state.status,
|
|
125
|
-
completedTasks: getCompletedCount(state.tasks),
|
|
126
|
-
totalTasks: state.tasks.length,
|
|
127
|
-
elapsedSeconds: Math.floor(getElapsedTimeSeconds(state)),
|
|
128
|
-
timestamp: Date.now(),
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function updateWidget(session: GoalSession, ctx: ExtensionContext): void {
|
|
133
|
-
if (!session.state || session.state.status === "cancelled") {
|
|
134
|
-
ctx.ui.setWidget("goal", undefined);
|
|
135
|
-
ctx.ui.setStatus("goal", undefined);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// 终态折叠为单行 status bar
|
|
140
|
-
if (isTerminalStatus(session.state.status)) {
|
|
141
|
-
const statusText = renderTerminalStatusLine(session.state, ctx.ui.theme);
|
|
142
|
-
if (statusText) {
|
|
143
|
-
ctx.ui.setStatus("goal", statusText);
|
|
144
|
-
}
|
|
145
|
-
ctx.ui.setWidget("goal", undefined);
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
ctx.ui.setStatus("goal", renderStatusLine(session.state, ctx.ui.theme));
|
|
150
|
-
ctx.ui.setWidget("goal", renderWidgetLines(session.state, ctx.ui.theme));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function clearGoalSession(session: GoalSession, ctx: ExtensionContext): void {
|
|
154
|
-
session.state = null;
|
|
155
|
-
session.tasksCompletedAtAgentStart = 0;
|
|
156
|
-
session.hasPendingInjection = false;
|
|
157
|
-
ctx.ui.setWidget("goal", undefined);
|
|
158
|
-
ctx.ui.setStatus("goal", undefined);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Result Builder ───────────────────────────────────
|
|
162
|
-
|
|
163
|
-
export function makeGoalResult(session: GoalSession, text: string) {
|
|
164
|
-
const state = session.state;
|
|
165
|
-
if (!state) {
|
|
166
|
-
// P1-1: 不再抛异常,返回标准 isError 结果
|
|
167
|
-
return errorResult("No active goal");
|
|
168
|
-
}
|
|
169
|
-
const budgetInfo: string[] = [];
|
|
170
|
-
if (state.budget.tokenBudget) {
|
|
171
|
-
const remaining = Math.max(state.budget.tokenBudget - state.tokensUsed, 0);
|
|
172
|
-
budgetInfo.push(`Token: ${state.tokensUsed}/${state.budget.tokenBudget} (${remaining} remaining)`);
|
|
173
|
-
}
|
|
174
|
-
if (state.budget.timeBudgetMinutes) {
|
|
175
|
-
const elapsed = getElapsedTimeSeconds(state);
|
|
176
|
-
const remaining = Math.max(state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE - elapsed, 0);
|
|
177
|
-
budgetInfo.push(`Time: ${Math.floor(elapsed / SECONDS_PER_MINUTE)}m/${state.budget.timeBudgetMinutes}m (${Math.floor(remaining / SECONDS_PER_MINUTE)}m remaining)`);
|
|
178
|
-
}
|
|
179
|
-
const suffix = budgetInfo.length > 0 ? `\n\n[Budget] ${budgetInfo.join(" | ")}` : "";
|
|
180
|
-
return {
|
|
181
|
-
content: [{ type: "text" as const, text: text + suffix }],
|
|
182
|
-
details: {
|
|
183
|
-
action: "update",
|
|
184
|
-
tasks: state.tasks.map((t) => ({ ...t })),
|
|
185
|
-
goalId: state.goalId,
|
|
186
|
-
status: state.status,
|
|
187
|
-
_render: {
|
|
188
|
-
type: "task-list" as const,
|
|
189
|
-
summary: `${getCompletedCount(state.tasks)}/${state.tasks.length} completed`,
|
|
190
|
-
data: {
|
|
191
|
-
items: state.tasks.map((t) => ({
|
|
192
|
-
id: t.id,
|
|
193
|
-
text: t.description,
|
|
194
|
-
status: t.status,
|
|
195
|
-
evidence: t.evidence,
|
|
196
|
-
subtasks: t.subtasks?.map((s) => ({
|
|
197
|
-
id: s.id,
|
|
198
|
-
text: s.text,
|
|
199
|
-
status: s.status,
|
|
200
|
-
})),
|
|
201
|
-
})),
|
|
202
|
-
meta: {
|
|
203
|
-
...(state.budget.tokenBudget ? { "Token": `${state.tokensUsed}/${state.budget.tokenBudget}` } : {}),
|
|
204
|
-
...(state.budget.timeBudgetMinutes ? { "Time": `${Math.floor(getElapsedTimeSeconds(state) / SECONDS_PER_MINUTE)}m/${state.budget.timeBudgetMinutes}m` } : {}),
|
|
205
|
-
"Turn": `${state.currentTurnIndex}/${state.budget.maxTurns}`,
|
|
206
|
-
},
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
} satisfies GoalManagerDetails,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ── Tool Execute Handler ──────────────────────────────
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* 执行 goal_manager tool action 的分发入口。
|
|
217
|
-
*
|
|
218
|
-
* 行为不变 —— 所有 case 逻辑已委托到 action-handlers.ts 中的独立函数。
|
|
219
|
-
* 这里仅做:状态检查 + signal 守卫 + action 查表分发。
|
|
220
|
-
*/
|
|
221
|
-
export async function executeGoalAction(
|
|
222
|
-
pi: ExtensionAPI,
|
|
223
|
-
session: GoalSession,
|
|
224
|
-
params: Static<typeof GoalManagerParams>,
|
|
225
|
-
ctx: ExtensionContext,
|
|
226
|
-
signal?: AbortSignal,
|
|
227
|
-
) {
|
|
228
|
-
const state = session.state;
|
|
229
|
-
if (!state) {
|
|
230
|
-
return errorResult("Goal mode not active. Use /goal <objective> to start.");
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// P1-4: signal 透传 — 若已 abort 直接返回错误
|
|
234
|
-
if (signal?.aborted) {
|
|
235
|
-
return errorResult("Tool call aborted by signal.");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 委托到 action-handlers.ts 中的具体处理器
|
|
239
|
-
const handler = ACTION_HANDLERS[params.action];
|
|
240
|
-
if (!handler) {
|
|
241
|
-
return errorResult(`Unknown action: ${params.action}`);
|
|
242
|
-
}
|
|
243
|
-
return handler({ pi, session, state, params, ctx });
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** 构造标准的错误结果(避免重复的 content 模板) */
|
|
247
|
-
export function errorResult(message: string) {
|
|
248
|
-
return {
|
|
249
|
-
content: [{ type: "text" as const, text: message }],
|
|
250
|
-
isError: true as const,
|
|
251
|
-
};
|
|
252
|
-
}
|
package/src/widget.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Widget 渲染逻辑 — 状态栏和侧边栏任务面板
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
|
|
7
|
-
import { getBudgetColor,getTimeUsagePercent, getTokenUsagePercent } from "./budget.js";
|
|
8
|
-
import {
|
|
9
|
-
OBJECTIVE_DISPLAY_LIMIT,
|
|
10
|
-
OBJECTIVE_TRUNCATE_KEEP,
|
|
11
|
-
PERCENT_FACTOR,
|
|
12
|
-
PROGRESS_BAR_DEFAULT_WIDTH,
|
|
13
|
-
SECONDS_PER_MINUTE,
|
|
14
|
-
} from "./constants";
|
|
15
|
-
import type { GoalRuntimeState } from "./state";
|
|
16
|
-
import { getCompletedCount, getElapsedTimeSeconds } from "./state";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* 将多行文本压缩为单行,用于 widget 渲染。
|
|
20
|
-
* 多行 content 泄漏到 widget 会导致 markdown 表格/标题等破坏布局。
|
|
21
|
-
*/
|
|
22
|
-
export function toSingleLine(text: string): string {
|
|
23
|
-
return text.replace(/\r?\n/g, " ").trim();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface ThemeLike {
|
|
27
|
-
fg: (color: ThemeColor, text: string) => string;
|
|
28
|
-
bold: (text: string) => string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function renderProgressBar(pct: number, width: number = PROGRESS_BAR_DEFAULT_WIDTH): string {
|
|
32
|
-
const clamped = Math.min(Math.max(pct, 0), 1);
|
|
33
|
-
const filled = Math.round(clamped * width);
|
|
34
|
-
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function renderStatusLine(state: GoalRuntimeState, th: ThemeLike): string {
|
|
38
|
-
if (state.status === "cancelled") return "";
|
|
39
|
-
|
|
40
|
-
const completedCount = getCompletedCount(state.tasks);
|
|
41
|
-
const total = state.tasks.length;
|
|
42
|
-
|
|
43
|
-
let text = th.fg("accent", `◆ Goal`) + th.fg("muted", ` ${state.currentTurnIndex}/${state.budget.maxTurns}`);
|
|
44
|
-
|
|
45
|
-
if (total > 0) {
|
|
46
|
-
text += th.fg("muted", ` | ${completedCount}/${total} tasks`);
|
|
47
|
-
const cancelledCount = state.tasks.filter(t => t.status === "cancelled").length;
|
|
48
|
-
if (cancelledCount > 0) {
|
|
49
|
-
text += th.fg("dim", `, ${cancelledCount} cancelled`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Budget indicators
|
|
54
|
-
if (state.budget.tokenBudget && state.budget.tokenBudget > 0) {
|
|
55
|
-
const pct = Math.round(getTokenUsagePercent(state));
|
|
56
|
-
text += th.fg(getBudgetColor(pct), ` | ${pct}% tokens`);
|
|
57
|
-
}
|
|
58
|
-
if (state.budget.timeBudgetMinutes && state.budget.timeBudgetMinutes > 0) {
|
|
59
|
-
const pct = Math.round(getTimeUsagePercent(state));
|
|
60
|
-
text += th.fg(getBudgetColor(pct), ` | ${pct}% time`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (state.stallCount > 0) {
|
|
64
|
-
text += th.fg("warning", ` | ⚠ ${state.stallCount} turns stalled`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Status suffix
|
|
68
|
-
switch (state.status) {
|
|
69
|
-
case "paused":
|
|
70
|
-
text += th.fg("warning", " | ⏸ Paused");
|
|
71
|
-
break;
|
|
72
|
-
case "blocked":
|
|
73
|
-
text += th.fg("error", " | ⊘ Blocked");
|
|
74
|
-
break;
|
|
75
|
-
case "complete":
|
|
76
|
-
text += th.fg("success", " | ✓ Completed");
|
|
77
|
-
break;
|
|
78
|
-
case "budget_limited":
|
|
79
|
-
text += th.fg("error", " | ⊗ Token budget exhausted");
|
|
80
|
-
break;
|
|
81
|
-
case "time_limited":
|
|
82
|
-
text += th.fg("error", " | ⏱ Time budget exhausted");
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return text;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function renderTerminalStatusLine(state: GoalRuntimeState, th: ThemeLike): string {
|
|
90
|
-
if (state.status === "cancelled") return "";
|
|
91
|
-
|
|
92
|
-
const completedCount = getCompletedCount(state.tasks);
|
|
93
|
-
const total = state.tasks.length;
|
|
94
|
-
|
|
95
|
-
let text = th.fg("accent", "◆ Goal");
|
|
96
|
-
|
|
97
|
-
// 状态后缀
|
|
98
|
-
switch (state.status) {
|
|
99
|
-
case "complete":
|
|
100
|
-
text += th.fg("success", " ✓ Completed");
|
|
101
|
-
break;
|
|
102
|
-
case "budget_limited":
|
|
103
|
-
text += th.fg("error", " ⊗ Token budget exhausted");
|
|
104
|
-
break;
|
|
105
|
-
case "time_limited":
|
|
106
|
-
text += th.fg("error", " ⏱ Time budget exhausted");
|
|
107
|
-
break;
|
|
108
|
-
default:
|
|
109
|
-
break;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
text += th.fg("muted", ` | ${completedCount}/${total} tasks`);
|
|
113
|
-
|
|
114
|
-
// 预算摘要
|
|
115
|
-
if (state.budget.tokenBudget && state.budget.tokenBudget > 0) {
|
|
116
|
-
const pct = Math.round(getTokenUsagePercent(state));
|
|
117
|
-
text += th.fg(getBudgetColor(pct), ` | ${pct}% tokens`);
|
|
118
|
-
}
|
|
119
|
-
if (state.budget.timeBudgetMinutes && state.budget.timeBudgetMinutes > 0) {
|
|
120
|
-
const pct = Math.round(getTimeUsagePercent(state));
|
|
121
|
-
text += th.fg(getBudgetColor(pct), ` | ${pct}% time`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return text;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function renderWidgetLines(state: GoalRuntimeState, th: ThemeLike): string[] {
|
|
128
|
-
if (state.status === "cancelled") return [];
|
|
129
|
-
|
|
130
|
-
const total = state.tasks.length;
|
|
131
|
-
|
|
132
|
-
// Header line
|
|
133
|
-
const header = renderStatusLine(state, th);
|
|
134
|
-
const lines: string[] = [header];
|
|
135
|
-
|
|
136
|
-
// Objective (single-line + truncated if too long)
|
|
137
|
-
const objSingleLine = toSingleLine(state.objective);
|
|
138
|
-
const objDisplay = objSingleLine.length > OBJECTIVE_DISPLAY_LIMIT
|
|
139
|
-
? objSingleLine.slice(0, OBJECTIVE_TRUNCATE_KEEP) + "..."
|
|
140
|
-
: objSingleLine;
|
|
141
|
-
lines.push(th.fg("dim", `Objective: ${objDisplay}`));
|
|
142
|
-
|
|
143
|
-
// Task list
|
|
144
|
-
if (total === 0) {
|
|
145
|
-
lines.push(th.fg("dim", " Waiting for task list creation..."));
|
|
146
|
-
} else {
|
|
147
|
-
for (const t of state.tasks) {
|
|
148
|
-
const desc = toSingleLine(t.description);
|
|
149
|
-
if (t.status === "completed") {
|
|
150
|
-
lines.push(` ${th.fg("success", "✓")} ${th.fg("dim", `#${t.id}`)} ${th.fg("dim", desc)}`);
|
|
151
|
-
} else if (t.status === "cancelled") {
|
|
152
|
-
lines.push(` ${th.fg("dim", "✗")} ${th.fg("dim", `#${t.id}`)} ${th.fg("dim", desc)}`);
|
|
153
|
-
} else if (t.status === "in_progress") {
|
|
154
|
-
lines.push(` ${th.fg("warning", "●")} ${th.fg("accent", `#${t.id}`)} ${th.fg("text", desc)}`);
|
|
155
|
-
} else {
|
|
156
|
-
lines.push(` ${th.fg("dim", "☐")} ${th.fg("accent", `#${t.id}`)} ${th.fg("text", desc)}`);
|
|
157
|
-
}
|
|
158
|
-
// Sub-todo items
|
|
159
|
-
if (t.subtasks && t.subtasks.length > 0 && t.status !== "cancelled") {
|
|
160
|
-
for (const s of t.subtasks) {
|
|
161
|
-
const subIcon = s.status === "completed"
|
|
162
|
-
? th.fg("success", "✓")
|
|
163
|
-
: s.status === "in_progress"
|
|
164
|
-
? th.fg("warning", "●")
|
|
165
|
-
: th.fg("dim", "○");
|
|
166
|
-
const subText = s.status === "completed" ? th.fg("dim", s.text) : th.fg("muted", s.text);
|
|
167
|
-
lines.push(` ${subIcon} ${th.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// P2-8: Budget progress bars
|
|
174
|
-
if (state.budget.tokenBudget && state.budget.tokenBudget > 0) {
|
|
175
|
-
const pct = getTokenUsagePercent(state) / PERCENT_FACTOR;
|
|
176
|
-
lines.push(` Token: ${renderProgressBar(pct)} ${Math.round(pct * PERCENT_FACTOR)}%`);
|
|
177
|
-
}
|
|
178
|
-
if (state.budget.timeBudgetMinutes && state.budget.timeBudgetMinutes > 0) {
|
|
179
|
-
const pct = getTimeUsagePercent(state) / PERCENT_FACTOR;
|
|
180
|
-
const elapsed = getElapsedTimeSeconds(state);
|
|
181
|
-
const mins = Math.floor(elapsed / SECONDS_PER_MINUTE);
|
|
182
|
-
lines.push(` Time: ${renderProgressBar(pct)} ${mins}/${state.budget.timeBudgetMinutes}min`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return lines;
|
|
186
|
-
}
|