@tt-a1i/openpi 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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
KeybindingsManager,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
Key,
|
|
8
|
+
matchesKey,
|
|
9
|
+
Text,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
type Component,
|
|
12
|
+
type TUI,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import type { TaskItem, TaskSnapshot } from "./tasks.ts";
|
|
15
|
+
|
|
16
|
+
const STATUS_ICON: Record<TaskItem["status"], string> = {
|
|
17
|
+
pending: "○",
|
|
18
|
+
in_progress: "●",
|
|
19
|
+
blocked: "!",
|
|
20
|
+
done: "✓",
|
|
21
|
+
dropped: "×",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const TASK_WIDGET_ORDER: Record<TaskItem["status"], number> = {
|
|
25
|
+
in_progress: 0,
|
|
26
|
+
blocked: 1,
|
|
27
|
+
pending: 2,
|
|
28
|
+
done: 3,
|
|
29
|
+
dropped: 4,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const TASK_WIDGET_LIMIT = 4;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A settled item's subject reads as struck-through and dimmed, so a glance at
|
|
36
|
+
* the list separates what is left from what is behind you.
|
|
37
|
+
*
|
|
38
|
+
* Both, not either: SGR 9 is widely but not universally supported, and a
|
|
39
|
+
* terminal that drops it would otherwise render done items identically to
|
|
40
|
+
* open ones. The dim color survives on its own.
|
|
41
|
+
*/
|
|
42
|
+
function subjectStyle(status: TaskItem["status"], theme: Theme) {
|
|
43
|
+
if (status === "done" || status === "dropped") {
|
|
44
|
+
return (text: string) => theme.strikethrough(theme.fg("dim", text));
|
|
45
|
+
}
|
|
46
|
+
// The one item being worked on is the only thing most glances are looking
|
|
47
|
+
// for, so it is the only one rendered at full weight.
|
|
48
|
+
if (status === "in_progress") {
|
|
49
|
+
return (text: string) => theme.bold(theme.fg("text", text));
|
|
50
|
+
}
|
|
51
|
+
return (text: string) =>
|
|
52
|
+
theme.fg(status === "blocked" ? "text" : "muted", text);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SUMMARY_LABEL: Record<TaskItem["status"], string> = {
|
|
56
|
+
done: "done",
|
|
57
|
+
in_progress: "in progress",
|
|
58
|
+
blocked: "blocked",
|
|
59
|
+
pending: "open",
|
|
60
|
+
dropped: "dropped",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Order the counts by how far along they are, not by status enum order. */
|
|
64
|
+
const SUMMARY_ORDER: TaskItem["status"][] = [
|
|
65
|
+
"done",
|
|
66
|
+
"in_progress",
|
|
67
|
+
"blocked",
|
|
68
|
+
"pending",
|
|
69
|
+
"dropped",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** Census of a whole batch, independent of which rows a view chooses to show. */
|
|
73
|
+
export type TaskCounts = Record<TaskItem["status"], number> & { total: number };
|
|
74
|
+
|
|
75
|
+
export function taskCounts(items: readonly TaskItem[]): TaskCounts {
|
|
76
|
+
const counts: TaskCounts = {
|
|
77
|
+
total: items.length,
|
|
78
|
+
pending: 0,
|
|
79
|
+
in_progress: 0,
|
|
80
|
+
blocked: 0,
|
|
81
|
+
done: 0,
|
|
82
|
+
dropped: 0,
|
|
83
|
+
};
|
|
84
|
+
for (const item of items) counts[item.status]++;
|
|
85
|
+
return counts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One-line census: `4 tasks (3 done, 1 in progress, 0 open)`.
|
|
90
|
+
*
|
|
91
|
+
* Counts carry the weight and the words recede, so the shape of the batch
|
|
92
|
+
* reads before any individual row does. `done`, `in progress`, and `open` are
|
|
93
|
+
* always listed even at zero — a stable set of three keeps the line from
|
|
94
|
+
* reflowing as work moves between them. `blocked` and `dropped` are
|
|
95
|
+
* exceptional and appear only when they are not zero.
|
|
96
|
+
*
|
|
97
|
+
* Takes counts rather than items because a view often shows a bounded subset
|
|
98
|
+
* of rows; the header must describe the whole batch regardless.
|
|
99
|
+
*/
|
|
100
|
+
export function renderTaskSummary(counts: TaskCounts, theme: Theme): string {
|
|
101
|
+
const number = (value: number) => theme.bold(theme.fg("text", String(value)));
|
|
102
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
103
|
+
// Coerced, not trusted: these counts can arrive from a tool-result record
|
|
104
|
+
// persisted by an older build, where a missing key would render the literal
|
|
105
|
+
// word "undefined" (or "NaN tasks") into the header.
|
|
106
|
+
const count = (status: TaskItem["status"]) =>
|
|
107
|
+
Number.isFinite(counts[status]) ? counts[status] : 0;
|
|
108
|
+
const total = Number.isFinite(counts.total) ? counts.total : 0;
|
|
109
|
+
// Built segment by segment rather than by wrapping the whole line: each
|
|
110
|
+
// styled run emits its own reset, so an outer color would stop applying at
|
|
111
|
+
// the first inner one.
|
|
112
|
+
const parts = SUMMARY_ORDER.filter(
|
|
113
|
+
(status) =>
|
|
114
|
+
status === "done" ||
|
|
115
|
+
status === "in_progress" ||
|
|
116
|
+
status === "pending" ||
|
|
117
|
+
count(status) > 0,
|
|
118
|
+
).map((status) => `${number(count(status))} ${dim(SUMMARY_LABEL[status])}`);
|
|
119
|
+
return [
|
|
120
|
+
number(total),
|
|
121
|
+
" ",
|
|
122
|
+
dim(total === 1 ? "task" : "tasks"),
|
|
123
|
+
" ",
|
|
124
|
+
dim("("),
|
|
125
|
+
parts.join(dim(", ")),
|
|
126
|
+
dim(")"),
|
|
127
|
+
].join("");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TaskToolDetails {
|
|
131
|
+
action: "add" | "update" | "list";
|
|
132
|
+
items: TaskItem[];
|
|
133
|
+
total: number;
|
|
134
|
+
revision: number;
|
|
135
|
+
batchClosed?: boolean;
|
|
136
|
+
/** Census of the whole batch; `items` is only the rows this call touched. */
|
|
137
|
+
counts?: TaskCounts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function renderTaskRows(
|
|
141
|
+
items: readonly TaskItem[],
|
|
142
|
+
theme: Theme,
|
|
143
|
+
width: number,
|
|
144
|
+
) {
|
|
145
|
+
if (items.length === 0) return [theme.fg("dim", "No task items.")];
|
|
146
|
+
// Ids address tasks in tasks_update, so they stay — but right-aligned, so a
|
|
147
|
+
// T10 appearing later never shifts every subject one column over.
|
|
148
|
+
// Floor of 3 ("T99"), not 2: the width is computed per view, so a batch
|
|
149
|
+
// crossing T9 — or the same batch shown collapsed (5 rows) then expanded
|
|
150
|
+
// (all of them) — would otherwise shift every subject sideways by a column.
|
|
151
|
+
const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3);
|
|
152
|
+
return items.flatMap((item) => {
|
|
153
|
+
const color =
|
|
154
|
+
item.status === "done"
|
|
155
|
+
? "success"
|
|
156
|
+
: item.status === "blocked"
|
|
157
|
+
? "warning"
|
|
158
|
+
: item.status === "dropped"
|
|
159
|
+
? "error"
|
|
160
|
+
: item.status === "in_progress"
|
|
161
|
+
? "accent"
|
|
162
|
+
: "muted";
|
|
163
|
+
// No `[status]` text: the icon, its color, and the subject's own weight
|
|
164
|
+
// already say it, and repeating it in words crowded every row.
|
|
165
|
+
const id = `T${item.id}`.padStart(idWidth);
|
|
166
|
+
const rows = [
|
|
167
|
+
truncateToWidth(
|
|
168
|
+
`${theme.fg(color, STATUS_ICON[item.status])} ${theme.fg("dim", id)} ${subjectStyle(item.status, theme)(item.subject)}`,
|
|
169
|
+
width,
|
|
170
|
+
),
|
|
171
|
+
];
|
|
172
|
+
// Continuation lines hang under the subject, not the icon, so the eye
|
|
173
|
+
// follows one left edge down the list.
|
|
174
|
+
const indent = " ".repeat(idWidth + 3);
|
|
175
|
+
if (item.detail) {
|
|
176
|
+
rows.push(
|
|
177
|
+
truncateToWidth(`${indent}${theme.fg("dim", item.detail)}`, width),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (item.note) {
|
|
181
|
+
const label =
|
|
182
|
+
item.status === "blocked"
|
|
183
|
+
? "Blocked"
|
|
184
|
+
: item.status === "done"
|
|
185
|
+
? "Evidence"
|
|
186
|
+
: item.status === "dropped"
|
|
187
|
+
? "Reason"
|
|
188
|
+
: "Note";
|
|
189
|
+
rows.push(
|
|
190
|
+
truncateToWidth(
|
|
191
|
+
`${indent}${theme.fg("dim", `${label}:`)} ${theme.fg("muted", item.note)}`,
|
|
192
|
+
width,
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return rows;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderTaskWidget(
|
|
201
|
+
snapshot: TaskSnapshot,
|
|
202
|
+
theme: Theme,
|
|
203
|
+
width: number,
|
|
204
|
+
expanded = false,
|
|
205
|
+
) {
|
|
206
|
+
const tracked = snapshot.items.filter((item) => item.status !== "dropped");
|
|
207
|
+
const actionable = tracked
|
|
208
|
+
.filter((item) => item.status !== "done")
|
|
209
|
+
.sort(
|
|
210
|
+
(left, right) =>
|
|
211
|
+
TASK_WIDGET_ORDER[left.status] - TASK_WIDGET_ORDER[right.status] ||
|
|
212
|
+
left.id - right.id,
|
|
213
|
+
);
|
|
214
|
+
if (actionable.length === 0) return [];
|
|
215
|
+
|
|
216
|
+
const hasOverflow = actionable.length > TASK_WIDGET_LIMIT;
|
|
217
|
+
const toggleHint = hasOverflow
|
|
218
|
+
? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
|
|
219
|
+
: "";
|
|
220
|
+
// Same census as the full list and the /tasks screen. Counted over `tracked`
|
|
221
|
+
// rather than every item, because the widget deliberately hides dropped work
|
|
222
|
+
// and a total that included it would not add up against the rows shown.
|
|
223
|
+
const header =
|
|
224
|
+
theme.fg("accent", "◆ ") +
|
|
225
|
+
theme.fg("text", theme.bold("Tasks")) +
|
|
226
|
+
" " +
|
|
227
|
+
renderTaskSummary(taskCounts(tracked), theme) +
|
|
228
|
+
theme.fg("dim", ` · /tasks${toggleHint}`);
|
|
229
|
+
const visible = expanded
|
|
230
|
+
? actionable
|
|
231
|
+
: actionable.slice(0, TASK_WIDGET_LIMIT);
|
|
232
|
+
const hidden = actionable.length - visible.length;
|
|
233
|
+
// Right-aligned like the full list, with the same floor: a widget whose ids
|
|
234
|
+
// are ragged next to a list whose ids are not reads as a different control.
|
|
235
|
+
const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3);
|
|
236
|
+
const lines = [truncateToWidth(header, width)];
|
|
237
|
+
for (const [index, item] of visible.entries()) {
|
|
238
|
+
const color =
|
|
239
|
+
item.status === "in_progress"
|
|
240
|
+
? "warning"
|
|
241
|
+
: item.status === "blocked"
|
|
242
|
+
? "error"
|
|
243
|
+
: "muted";
|
|
244
|
+
const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─";
|
|
245
|
+
lines.push(
|
|
246
|
+
truncateToWidth(
|
|
247
|
+
// Same subject weighting as the full list, so the item in flight reads
|
|
248
|
+
// the same wherever you happen to be looking.
|
|
249
|
+
`${theme.fg("dim", branch)} ${theme.fg(color, STATUS_ICON[item.status])} ${theme.fg("dim", `T${item.id}`.padStart(idWidth))} ${subjectStyle(item.status, theme)(item.subject)}`,
|
|
250
|
+
width,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (hidden > 0) {
|
|
255
|
+
lines.push(
|
|
256
|
+
truncateToWidth(theme.fg("dim", `╰─ … ${hidden} more tasks`), width),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Rows are built at the width they will be shown at, not at a fixed width and
|
|
264
|
+
* then re-wrapped. `Text` re-wraps with a wrapper that closes a line's colour
|
|
265
|
+
* and underline but NOT strikethrough, so a row laid out for a wider terminal
|
|
266
|
+
* and folded here left SGR 9 open across the fold — the padding to the right
|
|
267
|
+
* of the break rendered as a solid struck-through bar. `truncateToWidth`
|
|
268
|
+
* emits a full reset, so cutting at the real width is safe.
|
|
269
|
+
*/
|
|
270
|
+
class TaskResultView implements Component {
|
|
271
|
+
private readonly details: TaskToolDetails;
|
|
272
|
+
private readonly expanded: boolean;
|
|
273
|
+
private readonly theme: Theme;
|
|
274
|
+
|
|
275
|
+
constructor(details: TaskToolDetails, expanded: boolean, theme: Theme) {
|
|
276
|
+
this.details = details;
|
|
277
|
+
this.expanded = expanded;
|
|
278
|
+
this.theme = theme;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
render(width: number): string[] {
|
|
282
|
+
const theme = this.theme;
|
|
283
|
+
const details = this.details;
|
|
284
|
+
const items = this.expanded ? details.items : details.items.slice(0, 5);
|
|
285
|
+
const rows: string[] = [];
|
|
286
|
+
// A closed batch has already been cleared from the live snapshot, so a
|
|
287
|
+
// census here would read "0 tasks" directly above the rows it describes.
|
|
288
|
+
// The "Batch complete" line below says everything that is left to say.
|
|
289
|
+
if (!details.batchClosed && details.counts) {
|
|
290
|
+
rows.push(renderTaskSummary(details.counts, theme));
|
|
291
|
+
}
|
|
292
|
+
rows.push(...renderTaskRows(items, theme, width));
|
|
293
|
+
if (!this.expanded && details.items.length > items.length) {
|
|
294
|
+
rows.push(
|
|
295
|
+
theme.fg("dim", `… ${details.items.length - items.length} more`),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (details.batchClosed) {
|
|
299
|
+
rows.push(
|
|
300
|
+
theme.fg("success", "✓ Batch complete") +
|
|
301
|
+
theme.fg("dim", " · next request starts at T1"),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return rows.map((row) => truncateToWidth(row, width, "…"));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Nothing is cached between renders, so there is nothing to drop. */
|
|
308
|
+
invalidate() {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function renderToolResult(
|
|
312
|
+
details: unknown,
|
|
313
|
+
expanded: boolean,
|
|
314
|
+
theme: Theme,
|
|
315
|
+
fallbackText = "Tasks updated.",
|
|
316
|
+
): Component {
|
|
317
|
+
if (
|
|
318
|
+
!details ||
|
|
319
|
+
typeof details !== "object" ||
|
|
320
|
+
!Array.isArray((details as Partial<TaskToolDetails>).items)
|
|
321
|
+
) {
|
|
322
|
+
return new Text(theme.fg("dim", fallbackText), 0, 0);
|
|
323
|
+
}
|
|
324
|
+
return new TaskResultView(details as TaskToolDetails, expanded, theme);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
class TasksScreen implements Component {
|
|
328
|
+
private offset = 0;
|
|
329
|
+
private readonly tui: TUI;
|
|
330
|
+
private readonly theme: Theme;
|
|
331
|
+
private readonly keybindings: KeybindingsManager;
|
|
332
|
+
private readonly snapshot: TaskSnapshot;
|
|
333
|
+
private readonly done: () => void;
|
|
334
|
+
|
|
335
|
+
constructor(
|
|
336
|
+
tui: TUI,
|
|
337
|
+
theme: Theme,
|
|
338
|
+
keybindings: KeybindingsManager,
|
|
339
|
+
snapshot: TaskSnapshot,
|
|
340
|
+
done: () => void,
|
|
341
|
+
) {
|
|
342
|
+
this.tui = tui;
|
|
343
|
+
this.theme = theme;
|
|
344
|
+
this.keybindings = keybindings;
|
|
345
|
+
this.snapshot = snapshot;
|
|
346
|
+
this.done = done;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
handleInput(data: string) {
|
|
350
|
+
if (
|
|
351
|
+
this.keybindings.matches(data, "tui.select.cancel") ||
|
|
352
|
+
matchesKey(data, Key.escape)
|
|
353
|
+
) {
|
|
354
|
+
this.done();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (this.keybindings.matches(data, "tui.editor.cursorUp") || data === "k") {
|
|
358
|
+
this.offset = Math.max(0, this.offset - 1);
|
|
359
|
+
this.tui.requestRender();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (
|
|
363
|
+
this.keybindings.matches(data, "tui.editor.cursorDown") ||
|
|
364
|
+
data === "j"
|
|
365
|
+
) {
|
|
366
|
+
this.offset += 1;
|
|
367
|
+
this.tui.requestRender();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (this.keybindings.matches(data, "tui.editor.pageUp")) {
|
|
371
|
+
this.offset = Math.max(0, this.offset - 10);
|
|
372
|
+
this.tui.requestRender();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (this.keybindings.matches(data, "tui.editor.pageDown")) {
|
|
376
|
+
this.offset += 10;
|
|
377
|
+
this.tui.requestRender();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
render(width: number) {
|
|
382
|
+
const body = renderTaskRows(this.snapshot.items, this.theme, width - 4);
|
|
383
|
+
const rows = Math.max(8, (this.tui.terminal.rows || 30) - 8);
|
|
384
|
+
const maxOffset = Math.max(0, body.length - rows);
|
|
385
|
+
this.offset = Math.min(this.offset, maxOffset);
|
|
386
|
+
const visible = body.slice(this.offset, this.offset + rows);
|
|
387
|
+
const lines = [
|
|
388
|
+
truncateToWidth(
|
|
389
|
+
`${this.theme.fg("accent", this.theme.bold("Session tasks"))} ${renderTaskSummary(taskCounts(this.snapshot.items), this.theme)}`,
|
|
390
|
+
width,
|
|
391
|
+
),
|
|
392
|
+
this.theme.fg("border", "─".repeat(Math.max(0, width))),
|
|
393
|
+
...visible.map((line) => truncateToWidth(` ${line}`, width)),
|
|
394
|
+
];
|
|
395
|
+
while (lines.length < rows + 2) lines.push("");
|
|
396
|
+
lines.push(
|
|
397
|
+
truncateToWidth(
|
|
398
|
+
this.theme.fg("dim", "j/k or ↑/↓ scroll · pgup/pgdn page · esc close"),
|
|
399
|
+
width,
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
return lines;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
invalidate() {}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function openTasksScreen(
|
|
409
|
+
ctx: ExtensionCommandContext,
|
|
410
|
+
snapshot: TaskSnapshot,
|
|
411
|
+
) {
|
|
412
|
+
if (ctx.mode !== "tui") {
|
|
413
|
+
if (ctx.hasUI)
|
|
414
|
+
ctx.ui.notify(`${snapshot.items.length} task item(s)`, "info");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
await ctx.ui.custom<void>(
|
|
418
|
+
(tui, theme, keybindings, done) =>
|
|
419
|
+
new TasksScreen(tui, theme, keybindings, snapshot, () => done()),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close each finished request with the time it took, rendered under the final
|
|
3
|
+
* message. The entry is TUI-only: it is never sent to the model.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
interface TurnTimeData {
|
|
10
|
+
ms: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Sub-second requests are noise, not information. */
|
|
14
|
+
const MIN_REPORTED_MS = 1_000;
|
|
15
|
+
|
|
16
|
+
export function formatTurnDuration(ms: number) {
|
|
17
|
+
const seconds = Math.round(ms / 1000);
|
|
18
|
+
if (seconds < 60) return `${seconds}s`;
|
|
19
|
+
const minutes = Math.floor(seconds / 60);
|
|
20
|
+
if (minutes < 60) {
|
|
21
|
+
return `${minutes}m${(seconds % 60).toString().padStart(2, "0")}s`;
|
|
22
|
+
}
|
|
23
|
+
return `${Math.floor(minutes / 60)}h${(minutes % 60).toString().padStart(2, "0")}m`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function (pi: ExtensionAPI) {
|
|
27
|
+
// Measured from the first agent run to the settle that ends the request, so
|
|
28
|
+
// retries, auto-compaction, and queued continuations are all included.
|
|
29
|
+
let startedAt: number | undefined;
|
|
30
|
+
|
|
31
|
+
pi.registerEntryRenderer<TurnTimeData>(
|
|
32
|
+
"turn-time",
|
|
33
|
+
(entry, _options, theme) => {
|
|
34
|
+
const ms = entry.data?.ms;
|
|
35
|
+
return new Text(
|
|
36
|
+
theme.fg(
|
|
37
|
+
"dim",
|
|
38
|
+
`✳ Worked for ${ms === undefined ? "?" : formatTurnDuration(ms)}`,
|
|
39
|
+
),
|
|
40
|
+
1,
|
|
41
|
+
0,
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
pi.on("agent_start", () => {
|
|
47
|
+
startedAt ??= Date.now();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.on("agent_settled", () => {
|
|
51
|
+
if (startedAt === undefined) return;
|
|
52
|
+
const ms = Date.now() - startedAt;
|
|
53
|
+
startedAt = undefined;
|
|
54
|
+
if (ms < MIN_REPORTED_MS) return;
|
|
55
|
+
pi.appendEntry<TurnTimeData>("turn-time", { ms });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
pi.on("session_shutdown", () => {
|
|
59
|
+
startedAt = undefined;
|
|
60
|
+
});
|
|
61
|
+
}
|