@tt-a1i/openpi 0.3.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 +87 -24
- package/SETUP.md +3 -3
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/src/prompt.ts +1 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +35 -44
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +387 -88
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +18 -18
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +330 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/sessions/index.ts +70 -55
- package/extensions/setup/index.ts +6 -6
- package/extensions/shared/activity-status.ts +6 -5
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +7 -1
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +24 -5
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/text-projection.ts +56 -0
- package/extensions/shared/tool-surface.ts +13 -6
- package/extensions/subagents/index.ts +216 -170
- package/extensions/subagents/navigation.ts +52 -23
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backends/stub.ts +7 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +620 -537
- package/extensions/subagents/src/prompt.ts +153 -38
- package/extensions/subagents/src/result-artifact.ts +142 -0
- package/extensions/subagents/src/result-delivery.ts +50 -5
- package/extensions/subagents/src/runtime.ts +8 -5
- package/extensions/subagents/src/ui/takeover.ts +84 -109
- package/extensions/subagents/src/ui/transcript.ts +76 -42
- package/extensions/subagents/src/ui/wait-result.ts +1 -1
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/workflows/artifacts.ts +35 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +64 -0
- package/extensions/workflows/dashboard.ts +353 -173
- package/extensions/workflows/handoff.ts +62 -20
- package/extensions/workflows/index.ts +647 -387
- package/extensions/workflows/model.ts +57 -15
- package/extensions/workflows/navigation.ts +33 -14
- package/extensions/workflows/prompt.ts +104 -8
- package/extensions/workflows/replay-safety.ts +16 -6
- package/extensions/workflows/result-delivery.ts +189 -0
- package/extensions/workflows/sandbox-child.cjs +11 -0
- package/package.json +1 -1
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +7 -4
- package/skills/workflows/SKILL.md +53 -10
- package/extensions/subagents/src/format.ts +0 -48
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AppKeybinding,
|
|
3
3
|
KeybindingsManager,
|
|
4
|
+
Theme,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import type {
|
|
6
7
|
AutocompleteProvider,
|
|
@@ -328,6 +329,31 @@ export class BelowEditorNavigationEditor implements EditorComponent, Focusable {
|
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Metrics tail for a below-editor strip: quiet values, quieter separators, and
|
|
334
|
+
* a hint that recedes furthest.
|
|
335
|
+
*
|
|
336
|
+
* The whole tail used to be painted in the status colour, which made a routine
|
|
337
|
+
* "1 running · 9m51s · ↓ to manage" shout as loudly as a failure. The status
|
|
338
|
+
* already has a coloured glyph on the left edge, so the tail only borrows that
|
|
339
|
+
* colour for the one count that carries the outcome — and only once the run has
|
|
340
|
+
* settled, where the colour means something.
|
|
341
|
+
*/
|
|
342
|
+
export function renderNavigationMetrics(
|
|
343
|
+
theme: Theme,
|
|
344
|
+
parts: readonly (string | undefined)[],
|
|
345
|
+
hint: string,
|
|
346
|
+
emphasis?: Parameters<Theme["fg"]>[0],
|
|
347
|
+
) {
|
|
348
|
+
const present = parts.filter((part): part is string => Boolean(part));
|
|
349
|
+
const styled = present.map((part, index) =>
|
|
350
|
+
index === 0 && emphasis
|
|
351
|
+
? theme.fg(emphasis, part)
|
|
352
|
+
: theme.fg("muted", part),
|
|
353
|
+
);
|
|
354
|
+
return [...styled, theme.fg("dim", hint)].join(theme.fg("dim", " · "));
|
|
355
|
+
}
|
|
356
|
+
|
|
331
357
|
/** Fit a left label and right metrics into exactly one bounded terminal row. */
|
|
332
358
|
export function fitNavigationSides(left: string, right: string, width: number) {
|
|
333
359
|
const boundedRight = truncateToWidth(right, Math.max(0, width - 4), "…");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OPENPI_CAPABILITY_NAMES,
|
|
3
|
+
type OpenPiCapability,
|
|
4
|
+
} from "./tool-surface.ts";
|
|
5
|
+
|
|
6
|
+
const CAPABILITY_INTENT = {
|
|
7
|
+
search:
|
|
8
|
+
/\b(?:use|run)\s+(?:fd|rg)\b|\buse\s+(?:structured\s+)?(?:(?:file|code|content)\s+)?search\b|\b(?:structured|fast)\s+(?:file|code|content)\s+search\b|(?:使用|用|运行).{0,8}(?:fd|rg|git\s+(?:show|diff|log))|结构化(?:文件|代码|内容)搜索/iu,
|
|
9
|
+
delegate:
|
|
10
|
+
/\bsubagents?\b|(?:^|[.!?]\s+)(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\b(?:can|could|would)\s+you\s+(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\bparallel\s+agents?\b|(?:使用|用|启动|调用|来|开).{0,8}子代理|(?:多个?|多路)子代理|并行.{0,8}(?:代理|agent)|委派.{0,6}(?:任务|给|出去)/iu,
|
|
11
|
+
workflow: /\bworkflows?\b|(?:使用|用|运行|创建|构建).{0,8}工作流/iu,
|
|
12
|
+
background:
|
|
13
|
+
/\b(?:run|start|keep)\b.{0,40}\b(?:in the background|background\s+(?:process|terminal|job))\b|后台.{0,8}(?:运行|启动|进程|终端|任务)/iu,
|
|
14
|
+
session:
|
|
15
|
+
/\b(?:create|set|update|track)\s+(?:an?\s+)?(?:session\s+)?(?:goal|task list|tasks)\b|(?:设置|创建|更新|跟踪|追踪).{0,8}(?:目标|任务)/iu,
|
|
16
|
+
} as const satisfies Record<OpenPiCapability, RegExp>;
|
|
17
|
+
|
|
18
|
+
const CAPABILITY_GATEWAY_INTENT =
|
|
19
|
+
/\bopenpi\s+(?:capabilit(?:y|ies)|tools?|features?)\b|openpi.{0,8}(?:能力|工具|功能)/iu;
|
|
20
|
+
|
|
21
|
+
const CONDITIONAL_OR_NEGATED_INTENT =
|
|
22
|
+
/^(?:\s*(?:only\s+)?(?:if|when|unless|before|in case)\b)|\b(?:do not|don't|cannot|can't|not|no|never|avoid)\b|\b(?:if|unless)\b|\bwhen\s+(?:needed|required|necessary)\b|(?:如果|若|假如|除非|仅当|需要时|不要|不能|不用|不必|无需|避免|请勿|禁止)/iu;
|
|
23
|
+
|
|
24
|
+
function clauses(prompt: string) {
|
|
25
|
+
return prompt.split(/[\n.!?。!?;;]+/u);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isExplicitClause(clause: string) {
|
|
29
|
+
return !CONDITIONAL_OR_NEGATED_INTENT.test(clause);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One fail-closed interpretation of explicit user capability intent. The
|
|
34
|
+
* English names `subagent` and `workflow` are reserved authorization words;
|
|
35
|
+
* negated or conditional clauses remain inert. Runtime activation and
|
|
36
|
+
* pre-submit UI feedback both cross this seam, so they cannot drift.
|
|
37
|
+
*/
|
|
38
|
+
export function capabilitiesRequestedByPrompt(prompt: string) {
|
|
39
|
+
const promptClauses = clauses(prompt);
|
|
40
|
+
return OPENPI_CAPABILITY_NAMES.filter((capability) =>
|
|
41
|
+
promptClauses.some(
|
|
42
|
+
(clause) =>
|
|
43
|
+
isExplicitClause(clause) && CAPABILITY_INTENT[capability].test(clause),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requestsCapabilityGateway(prompt: string) {
|
|
49
|
+
return clauses(prompt).some(
|
|
50
|
+
(clause) =>
|
|
51
|
+
isExplicitClause(clause) && CAPABILITY_GATEWAY_INTENT.test(clause),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -22,7 +22,13 @@ export const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
|
22
22
|
* `registerTool(createEditToolDefinition(...))`); an unrecognized factory
|
|
23
23
|
* registration fails the guard rather than escaping it.
|
|
24
24
|
*/
|
|
25
|
-
export const CHILD_SAFE_PACKAGE_TOOL_NAMES = [
|
|
25
|
+
export const CHILD_SAFE_PACKAGE_TOOL_NAMES = [
|
|
26
|
+
"fd",
|
|
27
|
+
"rg",
|
|
28
|
+
"git_show",
|
|
29
|
+
"git_diff",
|
|
30
|
+
"git_log",
|
|
31
|
+
] as const;
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* pi-intercom resources are unsafe inside concurrent in-process child sessions.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export interface ParentContextUsage {
|
|
2
|
+
readonly tokens: number | null;
|
|
3
|
+
readonly contextWindow: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ResultBudgetPolicy {
|
|
7
|
+
readonly maxBatchBytes: number;
|
|
8
|
+
readonly maxResultBytes: number;
|
|
9
|
+
readonly minResultBytes: number;
|
|
10
|
+
readonly headroomShare: number;
|
|
11
|
+
readonly estimatedBytesPerToken: number;
|
|
12
|
+
/** Batch metadata that consumes the same parent-context headroom. */
|
|
13
|
+
readonly fixedBytes?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ResultBudgetAllocation {
|
|
17
|
+
readonly budgets: readonly number[];
|
|
18
|
+
readonly batchBytes: number;
|
|
19
|
+
readonly source: "static" | "dynamic";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validUsage(
|
|
23
|
+
usage: ParentContextUsage | null | undefined,
|
|
24
|
+
): usage is { readonly tokens: number; readonly contextWindow: number } {
|
|
25
|
+
return Boolean(
|
|
26
|
+
usage &&
|
|
27
|
+
typeof usage.tokens === "number" &&
|
|
28
|
+
Number.isFinite(usage.tokens) &&
|
|
29
|
+
usage.tokens >= 0 &&
|
|
30
|
+
Number.isFinite(usage.contextWindow) &&
|
|
31
|
+
usage.contextWindow > 0,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function distribute(
|
|
36
|
+
desired: readonly number[],
|
|
37
|
+
batchBytes: number,
|
|
38
|
+
minResultBytes: number,
|
|
39
|
+
) {
|
|
40
|
+
const budgets = desired.map((bytes) => Math.min(bytes, minResultBytes));
|
|
41
|
+
let remaining = Math.max(
|
|
42
|
+
0,
|
|
43
|
+
batchBytes - budgets.reduce((sum, bytes) => sum + bytes, 0),
|
|
44
|
+
);
|
|
45
|
+
let active = desired
|
|
46
|
+
.map((bytes, index) => ({ bytes, index }))
|
|
47
|
+
.filter(({ bytes, index }) => bytes > budgets[index]!);
|
|
48
|
+
|
|
49
|
+
while (remaining > 0 && active.length > 0) {
|
|
50
|
+
const share = Math.floor(remaining / active.length);
|
|
51
|
+
if (share === 0) {
|
|
52
|
+
for (const { bytes, index } of active) {
|
|
53
|
+
if (remaining === 0) break;
|
|
54
|
+
if (budgets[index]! >= bytes) continue;
|
|
55
|
+
budgets[index]! += 1;
|
|
56
|
+
remaining--;
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const satisfied = active.filter(
|
|
62
|
+
({ bytes, index }) => bytes - budgets[index]! <= share,
|
|
63
|
+
);
|
|
64
|
+
if (satisfied.length > 0) {
|
|
65
|
+
for (const { bytes, index } of satisfied) {
|
|
66
|
+
const addition = bytes - budgets[index]!;
|
|
67
|
+
budgets[index]! = bytes;
|
|
68
|
+
remaining -= addition;
|
|
69
|
+
}
|
|
70
|
+
const settled = new Set(satisfied.map(({ index }) => index));
|
|
71
|
+
active = active.filter(({ index }) => !settled.has(index));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const { index } of active) budgets[index]! += share;
|
|
76
|
+
remaining -= share * active.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return budgets;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Allocate one bounded projection budget per result. Small results yield their
|
|
84
|
+
* unused share to larger siblings. Parent context usage is optional: Pi marks
|
|
85
|
+
* it unknown after compaction until a fresh response, in which case the
|
|
86
|
+
* deterministic static batch cap remains the source of truth.
|
|
87
|
+
*/
|
|
88
|
+
export function allocateResultBudgets(
|
|
89
|
+
resultBytes: readonly number[],
|
|
90
|
+
usage: ParentContextUsage | null | undefined,
|
|
91
|
+
policy: ResultBudgetPolicy,
|
|
92
|
+
): ResultBudgetAllocation {
|
|
93
|
+
if (resultBytes.length === 0) {
|
|
94
|
+
return { budgets: [], batchBytes: 0, source: "static" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const desired = resultBytes.map((bytes) =>
|
|
98
|
+
Math.min(policy.maxResultBytes, Math.max(0, Math.floor(bytes))),
|
|
99
|
+
);
|
|
100
|
+
const perResultFloor = Math.min(
|
|
101
|
+
policy.minResultBytes,
|
|
102
|
+
Math.floor(policy.maxBatchBytes / resultBytes.length),
|
|
103
|
+
);
|
|
104
|
+
const desiredTotal = desired.reduce((sum, bytes) => sum + bytes, 0);
|
|
105
|
+
const staticBatchBytes = Math.min(policy.maxBatchBytes, desiredTotal);
|
|
106
|
+
const minimumBatchBytes = desired.reduce(
|
|
107
|
+
(sum, bytes) => sum + Math.min(bytes, perResultFloor),
|
|
108
|
+
0,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
let batchBytes = staticBatchBytes;
|
|
112
|
+
let source: ResultBudgetAllocation["source"] = "static";
|
|
113
|
+
if (validUsage(usage)) {
|
|
114
|
+
const headroomTokens = Math.max(0, usage.contextWindow - usage.tokens);
|
|
115
|
+
const dynamicBytes = Math.max(
|
|
116
|
+
0,
|
|
117
|
+
Math.floor(
|
|
118
|
+
headroomTokens * policy.headroomShare * policy.estimatedBytesPerToken,
|
|
119
|
+
) - Math.max(0, policy.fixedBytes ?? 0),
|
|
120
|
+
);
|
|
121
|
+
const narrowed = Math.max(
|
|
122
|
+
minimumBatchBytes,
|
|
123
|
+
Math.min(staticBatchBytes, dynamicBytes),
|
|
124
|
+
);
|
|
125
|
+
if (narrowed < staticBatchBytes) source = "dynamic";
|
|
126
|
+
batchBytes = narrowed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
budgets: distribute(desired, batchBytes, perResultFloor),
|
|
131
|
+
batchBytes,
|
|
132
|
+
source,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Only the two paint calls this chrome makes, following the footer's precedent:
|
|
6
|
+
* views that carry a narrowed theme object can use it without a cast.
|
|
7
|
+
*/
|
|
8
|
+
type Theme = Pick<ExtensionContext["ui"]["theme"], "fg" | "bold">;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Some labels arrive already styled (a task census colours each state). Painting
|
|
12
|
+
* a colour over them would apply only up to their first inner reset, leaving the
|
|
13
|
+
* rest a different shade than the caller asked for, so pre-styled text is passed
|
|
14
|
+
* through untouched.
|
|
15
|
+
*/
|
|
16
|
+
const isStyled = (text: string) => text.includes("\u001b");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The chrome every full-screen OpenPI view shares: a title line, one bordered
|
|
20
|
+
* panel, a hint line, and one way to say "there is more above/below".
|
|
21
|
+
*
|
|
22
|
+
* Three views had grown their own copy of this (`/subagents`, `/ps`,
|
|
23
|
+
* `/workflows`), and they had drifted apart in the details you notice without
|
|
24
|
+
* being able to name: one framed the body in `border`, another in
|
|
25
|
+
* `borderMuted`; one wrote `... 3 more` and another `… 3 more`; hints were a
|
|
26
|
+
* single dim run in which the keys you are supposed to press read exactly as
|
|
27
|
+
* faint as the prose describing them. Fixing that in one place is also the only
|
|
28
|
+
* way it stays fixed.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Title left, quiet census right, with the same one-column inset as the panel. */
|
|
32
|
+
export function screenTitleLine(
|
|
33
|
+
theme: Theme,
|
|
34
|
+
title: string,
|
|
35
|
+
meta: string,
|
|
36
|
+
width: number,
|
|
37
|
+
) {
|
|
38
|
+
const left = ` ${theme.bold(theme.fg("accent", title))}`;
|
|
39
|
+
const right = meta ? `${isStyled(meta) ? meta : theme.fg("dim", meta)} ` : "";
|
|
40
|
+
const rightWidth = visibleWidth(right);
|
|
41
|
+
const fittedLeft = truncateToWidth(
|
|
42
|
+
left,
|
|
43
|
+
Math.max(0, width - rightWidth - 1),
|
|
44
|
+
"…",
|
|
45
|
+
);
|
|
46
|
+
const pad = Math.max(1, width - visibleWidth(fittedLeft) - rightWidth);
|
|
47
|
+
return truncateToWidth(fittedLeft + " ".repeat(pad) + right, width, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bordered panel with an optional label set into the top edge, padded to an
|
|
52
|
+
* exact height so a view's overlay never changes size as content streams in.
|
|
53
|
+
*/
|
|
54
|
+
export function panelFrame(
|
|
55
|
+
theme: Theme,
|
|
56
|
+
options: {
|
|
57
|
+
label?: string;
|
|
58
|
+
rows: readonly string[];
|
|
59
|
+
width: number;
|
|
60
|
+
height: number;
|
|
61
|
+
},
|
|
62
|
+
) {
|
|
63
|
+
const { label = "", rows, width, height } = options;
|
|
64
|
+
const inner = Math.max(0, width - 2);
|
|
65
|
+
const border = (text: string) => theme.fg("borderMuted", text);
|
|
66
|
+
// The label rides the border rather than sitting above it, so it reads as
|
|
67
|
+
// this panel's name instead of competing with the screen title.
|
|
68
|
+
const clippedLabel = label
|
|
69
|
+
? truncateToWidth(` ${label} `, Math.max(0, inner - 2))
|
|
70
|
+
: "";
|
|
71
|
+
const labelText = clippedLabel
|
|
72
|
+
? isStyled(clippedLabel)
|
|
73
|
+
? clippedLabel
|
|
74
|
+
: theme.fg("muted", clippedLabel)
|
|
75
|
+
: "";
|
|
76
|
+
const dashes = Math.max(0, inner - visibleWidth(labelText) - 1);
|
|
77
|
+
const lines = [border("╭─") + labelText + border("─".repeat(dashes) + "╮")];
|
|
78
|
+
const bodyHeight = Math.max(0, height - 2);
|
|
79
|
+
for (let index = 0; index < bodyHeight; index += 1) {
|
|
80
|
+
const clipped = truncateToWidth(rows[index] ?? "", inner, "…");
|
|
81
|
+
const pad = Math.max(0, inner - visibleWidth(clipped));
|
|
82
|
+
lines.push(border("│") + clipped + " ".repeat(pad) + border("│"));
|
|
83
|
+
}
|
|
84
|
+
lines.push(border("╰" + "─".repeat(inner) + "╯"));
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One `keys label` pair of a hint line. */
|
|
89
|
+
export type ScreenHint = readonly [keys: string, label: string];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Keys read one step brighter than what they do, so the line scans as a
|
|
93
|
+
* keyboard legend instead of a sentence. A notice takes the whole line when
|
|
94
|
+
* there is one: it is the answer to what you just pressed, and the legend can
|
|
95
|
+
* wait a beat.
|
|
96
|
+
*/
|
|
97
|
+
export function hintLine(
|
|
98
|
+
theme: Theme,
|
|
99
|
+
hints: readonly (ScreenHint | undefined)[],
|
|
100
|
+
width: number,
|
|
101
|
+
notice?: string,
|
|
102
|
+
) {
|
|
103
|
+
if (notice) {
|
|
104
|
+
return truncateToWidth(theme.fg("accent", ` ${notice}`), width, "…");
|
|
105
|
+
}
|
|
106
|
+
const parts = hints
|
|
107
|
+
.filter((hint): hint is ScreenHint => Boolean(hint))
|
|
108
|
+
.map(([keys, label]) => {
|
|
109
|
+
// An empty key slot is a plain status segment (a scroll position, say);
|
|
110
|
+
// an empty label is a bare key. Both stay dimmer than a real key.
|
|
111
|
+
if (!keys) return theme.fg("dim", label);
|
|
112
|
+
if (!label) return theme.fg("muted", keys);
|
|
113
|
+
return `${theme.fg("muted", keys)} ${theme.fg("dim", label)}`;
|
|
114
|
+
});
|
|
115
|
+
return truncateToWidth(
|
|
116
|
+
` ${parts.join(theme.fg("dim", " · "))}`,
|
|
117
|
+
width,
|
|
118
|
+
theme.fg("dim", "…"),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Single vocabulary for a clipped list: `… 3 more agents`. */
|
|
123
|
+
export function overflowNote(
|
|
124
|
+
theme: Theme,
|
|
125
|
+
count: number,
|
|
126
|
+
width: number,
|
|
127
|
+
noun = "",
|
|
128
|
+
) {
|
|
129
|
+
return truncateToWidth(
|
|
130
|
+
theme.fg("dim", ` … ${count} more${noun ? ` ${noun}` : ""}`),
|
|
131
|
+
width,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -69,7 +69,12 @@ export type CapabilityDiscoveryMode =
|
|
|
69
69
|
|
|
70
70
|
/** Canonical default layout: one-line plain footer with flex alignment. */
|
|
71
71
|
export const DEFAULT_FOOTER_LINES: FooterLines = [
|
|
72
|
-
["
|
|
72
|
+
["model", "context", "flex", "git", "pr", "cwd"],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** The persisted canonical default before model/context moved to the left. */
|
|
76
|
+
const LEGACY_DEFAULT_FOOTER_LINES: FooterLines = [
|
|
77
|
+
["cwd", "git", "pr", "flex", "model", "context"],
|
|
73
78
|
];
|
|
74
79
|
|
|
75
80
|
export const DEFAULT_FOOTER_STYLE: FooterStyle = "plain";
|
|
@@ -88,7 +93,7 @@ export const FOOTER_PRESET_DEFINITIONS: Record<
|
|
|
88
93
|
> = {
|
|
89
94
|
compact: {
|
|
90
95
|
style: "plain",
|
|
91
|
-
lines:
|
|
96
|
+
lines: DEFAULT_FOOTER_LINES,
|
|
92
97
|
},
|
|
93
98
|
powerline: {
|
|
94
99
|
style: "powerline",
|
|
@@ -340,6 +345,17 @@ function parseFooterItems(value: unknown): readonly FooterItem[] {
|
|
|
340
345
|
return items.length > 0 ? items : DEFAULT_FOOTER_ITEMS;
|
|
341
346
|
}
|
|
342
347
|
|
|
348
|
+
function sameFooterLines(left: FooterLines, right: FooterLines) {
|
|
349
|
+
return (
|
|
350
|
+
left.length === right.length &&
|
|
351
|
+
left.every(
|
|
352
|
+
(line, lineIndex) =>
|
|
353
|
+
line.length === right[lineIndex]?.length &&
|
|
354
|
+
line.every((item, itemIndex) => item === right[lineIndex]?.[itemIndex]),
|
|
355
|
+
)
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
343
359
|
function parseUiFooter(
|
|
344
360
|
ui: Record<string, unknown>,
|
|
345
361
|
): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines" | "footerItems"> {
|
|
@@ -348,7 +364,10 @@ function parseUiFooter(
|
|
|
348
364
|
: DEFAULT_FOOTER_STYLE;
|
|
349
365
|
|
|
350
366
|
if (ui.footerLines !== undefined) {
|
|
351
|
-
const
|
|
367
|
+
const normalized = normalizeFooterLines(ui.footerLines);
|
|
368
|
+
const lines = sameFooterLines(normalized, LEGACY_DEFAULT_FOOTER_LINES)
|
|
369
|
+
? DEFAULT_FOOTER_LINES
|
|
370
|
+
: normalized;
|
|
352
371
|
return {
|
|
353
372
|
footerStyle: style,
|
|
354
373
|
footerLines: lines,
|
|
@@ -957,8 +976,8 @@ export function formatSetupConfig(
|
|
|
957
976
|
`Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`,
|
|
958
977
|
`UI: large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`,
|
|
959
978
|
`Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact status summary (Ctrl+O expands full output)"}`,
|
|
960
|
-
`Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "
|
|
961
|
-
`Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "
|
|
979
|
+
`Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
|
|
980
|
+
`Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
|
|
962
981
|
`Post-edit command: ${config.postEdit.command ? config.postEdit.command : "off"}`,
|
|
963
982
|
`Agent role models (Subagents + Workflows): ${SUBAGENT_ROLE_NAMES.map((role) => `${role} ${config.subagents.roleModels[role] ? `${config.subagents.roleModels[role].provider}/${config.subagents.roleModels[role].model}` : "inherit"}`).join(" · ")}`,
|
|
964
983
|
...integrationLines,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One braille spinner for every running-state indicator in the package:
|
|
3
|
+
* transcripts, takeover and dashboard headers, and the below-editor strips all
|
|
4
|
+
* advance on the same cadence so concurrent views animate in step.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const SPINNER_FRAMES = [
|
|
8
|
+
"⠋",
|
|
9
|
+
"⠙",
|
|
10
|
+
"⠹",
|
|
11
|
+
"⠸",
|
|
12
|
+
"⠼",
|
|
13
|
+
"⠴",
|
|
14
|
+
"⠦",
|
|
15
|
+
"⠧",
|
|
16
|
+
"⠇",
|
|
17
|
+
"⠏",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/** Frame cadence, shared with the dashboard and takeover headers. */
|
|
21
|
+
export const SPINNER_INTERVAL_MS = 120;
|
|
22
|
+
|
|
23
|
+
export function spinnerFrame(now: number) {
|
|
24
|
+
const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length;
|
|
25
|
+
return SPINNER_FRAMES[
|
|
26
|
+
(frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length
|
|
27
|
+
];
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const HEAD_SHARE = 0.75;
|
|
4
|
+
|
|
5
|
+
function utf8Prefix(content: string, maxBytes: number) {
|
|
6
|
+
const bytes = Buffer.from(content, "utf8");
|
|
7
|
+
if (bytes.length <= maxBytes) return content;
|
|
8
|
+
let end = Math.max(0, maxBytes);
|
|
9
|
+
while (end > 0) {
|
|
10
|
+
const value = bytes.subarray(0, end).toString("utf8");
|
|
11
|
+
if (!value.endsWith("�")) return value;
|
|
12
|
+
end -= 1;
|
|
13
|
+
}
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function utf8Suffix(content: string, maxBytes: number) {
|
|
18
|
+
const bytes = Buffer.from(content, "utf8");
|
|
19
|
+
if (bytes.length <= maxBytes) return content;
|
|
20
|
+
let start = Math.max(0, bytes.length - maxBytes);
|
|
21
|
+
while (start < bytes.length) {
|
|
22
|
+
const value = bytes.subarray(start).toString("utf8");
|
|
23
|
+
if (!value.startsWith("�")) return value;
|
|
24
|
+
start += 1;
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep decision context at the start and verdict/evidence at the end. */
|
|
30
|
+
export function projectText(
|
|
31
|
+
content: string,
|
|
32
|
+
options: { maxBytes: number; maxLines: number; recovery: string },
|
|
33
|
+
) {
|
|
34
|
+
const probe = truncateHead(content, {
|
|
35
|
+
maxBytes: options.maxBytes,
|
|
36
|
+
maxLines: options.maxLines,
|
|
37
|
+
});
|
|
38
|
+
if (!probe.truncated) return content;
|
|
39
|
+
|
|
40
|
+
let bodyBudget = options.maxBytes;
|
|
41
|
+
let projected = "";
|
|
42
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
43
|
+
const headBytes = Math.max(1, Math.floor(bodyBudget * HEAD_SHARE));
|
|
44
|
+
const tailBytes = Math.max(1, bodyBudget - headBytes);
|
|
45
|
+
const head = utf8Prefix(content, headBytes);
|
|
46
|
+
const tail = utf8Suffix(content, tailBytes);
|
|
47
|
+
const shown =
|
|
48
|
+
Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
|
|
49
|
+
const footer = `[Projection bounded: showing ${formatSize(shown)} of ${formatSize(probe.totalBytes)} across the head and tail. ${options.recovery}]`;
|
|
50
|
+
projected = `${head}\n\n[... middle omitted ...]\n\n${tail}\n\n${footer}`;
|
|
51
|
+
const overflow = Buffer.byteLength(projected, "utf8") - options.maxBytes;
|
|
52
|
+
if (overflow <= 0 || bodyBudget <= overflow + 2) break;
|
|
53
|
+
bodyBudget -= overflow;
|
|
54
|
+
}
|
|
55
|
+
return projected;
|
|
56
|
+
}
|
|
@@ -17,19 +17,24 @@ export const OPENPI_TOOL_SURFACE = {
|
|
|
17
17
|
entry: ["fd", "rg"],
|
|
18
18
|
deferred: [],
|
|
19
19
|
},
|
|
20
|
+
gitRead: {
|
|
21
|
+
entry: ["git_show", "git_diff", "git_log"],
|
|
22
|
+
deferred: [],
|
|
23
|
+
},
|
|
20
24
|
subagents: {
|
|
21
|
-
entry: [
|
|
22
|
-
|
|
25
|
+
entry: [
|
|
26
|
+
"subagent_spawn",
|
|
23
27
|
"subagent_wait",
|
|
24
28
|
"subagent_cancel",
|
|
25
29
|
"subagent_send",
|
|
26
30
|
"subagent_check",
|
|
27
31
|
"subagent_list",
|
|
28
32
|
],
|
|
33
|
+
deferred: [],
|
|
29
34
|
},
|
|
30
35
|
workflows: {
|
|
31
|
-
entry: ["workflow"],
|
|
32
|
-
deferred: [
|
|
36
|
+
entry: ["workflow", "workflow_stop", "workflow_status"],
|
|
37
|
+
deferred: [],
|
|
33
38
|
},
|
|
34
39
|
background: {
|
|
35
40
|
entry: ["bg_start"],
|
|
@@ -65,8 +70,9 @@ export type OpenPiToolOwner = keyof typeof OPENPI_TOOL_SURFACE;
|
|
|
65
70
|
|
|
66
71
|
export const OPENPI_CAPABILITY_GROUPS = {
|
|
67
72
|
search: {
|
|
68
|
-
owners: ["fileSearch"],
|
|
69
|
-
summary:
|
|
73
|
+
owners: ["fileSearch", "gitRead"],
|
|
74
|
+
summary:
|
|
75
|
+
"Fast structured file and content search (fd, rg) plus read-only git inspection (git_show, git_diff, git_log).",
|
|
70
76
|
},
|
|
71
77
|
delegate: {
|
|
72
78
|
owners: ["subagents"],
|
|
@@ -136,6 +142,7 @@ const OWNER_SOURCE_PATHS = {
|
|
|
136
142
|
fileSearch: fileURLToPath(
|
|
137
143
|
new URL("../file-search/index.ts", import.meta.url),
|
|
138
144
|
),
|
|
145
|
+
gitRead: fileURLToPath(new URL("../git-read/index.ts", import.meta.url)),
|
|
139
146
|
subagents: fileURLToPath(new URL("../subagents/index.ts", import.meta.url)),
|
|
140
147
|
workflows: fileURLToPath(new URL("../workflows/index.ts", import.meta.url)),
|
|
141
148
|
background: fileURLToPath(
|