@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.
Files changed (61) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +35 -44
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +216 -170
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/result-delivery.ts +50 -5
  38. package/extensions/subagents/src/runtime.ts +8 -5
  39. package/extensions/subagents/src/ui/takeover.ts +84 -109
  40. package/extensions/subagents/src/ui/transcript.ts +76 -42
  41. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  42. package/extensions/tasks/ui.ts +79 -62
  43. package/extensions/ui-customization/footer.ts +7 -4
  44. package/extensions/user-input-fold/index.ts +185 -0
  45. package/extensions/workflows/artifacts.ts +35 -0
  46. package/extensions/workflows/controller.ts +14 -2
  47. package/extensions/workflows/coordinator.ts +64 -0
  48. package/extensions/workflows/dashboard.ts +353 -173
  49. package/extensions/workflows/handoff.ts +62 -20
  50. package/extensions/workflows/index.ts +647 -387
  51. package/extensions/workflows/model.ts +57 -15
  52. package/extensions/workflows/navigation.ts +33 -14
  53. package/extensions/workflows/prompt.ts +104 -8
  54. package/extensions/workflows/replay-safety.ts +16 -6
  55. package/extensions/workflows/result-delivery.ts +189 -0
  56. package/extensions/workflows/sandbox-child.cjs +11 -0
  57. package/package.json +1 -1
  58. package/skills/subagents/SKILL.md +2 -2
  59. package/skills/workflows/REFERENCE.md +7 -4
  60. package/skills/workflows/SKILL.md +53 -10
  61. 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 = ["fd", "rg"] as const;
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
- ["cwd", "git", "pr", "flex", "model", "context", "cost"],
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: [["cwd", "model", "thinking", "context", "flex", "git", "pr"]],
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 lines = normalizeFooterLines(ui.footerLines);
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" : "folded preview (Ctrl+O expands all)"}`,
961
- `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
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: ["subagent_spawn"],
22
- deferred: [
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: ["workflow_stop", "workflow_status"],
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: "Fast structured file and content search with fd and rg.",
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(