pi-one-ui 0.1.1

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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,357 @@
1
+ import { VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings } from "@earendil-works/pi-tui";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { config } from "../../app/config/renderer.ts";
5
+ import { ansi16ToRgb, ansi256ToRgb } from "../../tools/ansi-color.ts";
6
+ type Rgb = [number, number, number];
7
+ type StyledPart = {
8
+ raw: string;
9
+ styled: string;
10
+ };
11
+
12
+ const ANSI_RESET = "\x1b[0m";
13
+ const ANSI_PATTERN =
14
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
15
+
16
+ // 官方 install.sh 静态 logo(4 行原样,短行补尾随空格统一到 8 列)+ 底部空行补到 5 行,
17
+ // 与右侧 tips 行数等高;着色保持现状(accent 渐变)
18
+ const LOGO_LINES = ["██████ ", "██ ██ ", "████ ██", "██ ██", " "];
19
+
20
+ // hero 文案(单行,替换原生 header 的 "Pi can explain..." 默认位置)
21
+ const HERO_PREFIX = "There are many agent harnesses, but this one is ";
22
+ const HERO_HIGHLIGHT = "yours";
23
+ const HERO_SUFFIX = ".";
24
+
25
+ // 左右双栏布局:左侧 logo(5 行),右侧原生提示(5 行)
26
+ const TWO_COL_GAP = 2;
27
+ // 窄于该宽度回退为垂直堆叠(logo + hero)
28
+ const TWO_COL_MIN_WIDTH = 48;
29
+
30
+ const FALLBACK_ACCENT_RGB: Rgb = [80, 160, 255];
31
+ const LOGO_BLOCK_WIDTH = Math.max(
32
+ ...LOGO_LINES.map((line) => [...line].length),
33
+ );
34
+ // 左栏宽度 = logo 宽,右侧栏从该宽度后开始
35
+ const LEFT_COLUMN_WIDTH = LOGO_BLOCK_WIDTH;
36
+
37
+ const PALETTE_STEPS = 24;
38
+ const PALETTE_MAX_DARKEN = 0.18;
39
+ const PALETTE_MAX_LIGHTEN = 0.18;
40
+ // 行内渐变波长:1/4 全波长(暗→亮单调,避免小字符数行内来回跳变)
41
+ const PALETTE_SPAN = 0.25;
42
+ // 行间相位偏移:小步累加 → 整体左上暗→右下亮的对角渐变
43
+ const LOGO_ROW_PHASE_STEP = 0.08;
44
+
45
+ function stripAnsi(text: string): string {
46
+ return text.replace(ANSI_PATTERN, "");
47
+ }
48
+
49
+ function getVisibleLength(text: string): number {
50
+ return [...stripAnsi(text)].length;
51
+ }
52
+
53
+ function clampByte(value: number): number {
54
+ return Math.max(0, Math.min(255, Math.round(value)));
55
+ }
56
+
57
+ function interpolateChannel(
58
+ start: number,
59
+ end: number,
60
+ factor: number,
61
+ ): number {
62
+ return Math.round(start + (end - start) * factor);
63
+ }
64
+
65
+ function interpolateRgb(start: Rgb, end: Rgb, factor: number): Rgb {
66
+ return [
67
+ interpolateChannel(start[0], end[0], factor),
68
+ interpolateChannel(start[1], end[1], factor),
69
+ interpolateChannel(start[2], end[2], factor),
70
+ ];
71
+ }
72
+
73
+ function darkenRgb(rgb: Rgb, amount: number): Rgb {
74
+ return [
75
+ clampByte(rgb[0] * (1 - amount)),
76
+ clampByte(rgb[1] * (1 - amount)),
77
+ clampByte(rgb[2] * (1 - amount)),
78
+ ];
79
+ }
80
+
81
+ function lightenRgb(rgb: Rgb, amount: number): Rgb {
82
+ return [
83
+ clampByte(rgb[0] + (255 - rgb[0]) * amount),
84
+ clampByte(rgb[1] + (255 - rgb[1]) * amount),
85
+ clampByte(rgb[2] + (255 - rgb[2]) * amount),
86
+ ];
87
+ }
88
+
89
+ function applyTruecolor(rgb: Rgb, text: string): string {
90
+ const [red, green, blue] = rgb;
91
+ return `\x1b[38;2;${red};${green};${blue}m${text}${ANSI_RESET}`;
92
+ }
93
+
94
+ function parseTruecolorAnsi(ansi: string): Rgb | undefined {
95
+ const match = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
96
+ if (!match) return undefined;
97
+
98
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
99
+ }
100
+
101
+ function parseAnsi256Foreground(ansi: string): Rgb | undefined {
102
+ const match = ansi.match(/38;5;(\d+)/);
103
+ if (!match) return undefined;
104
+
105
+ return ansi256ToRgb(Number(match[1]));
106
+ }
107
+
108
+ function parseAnsi16Foreground(ansi: string): Rgb | undefined {
109
+ const normalMatch = ansi.match(/(?:\[|;)(3[0-7])(?:;|m)/);
110
+ if (normalMatch) {
111
+ return ansi16ToRgb(Number(normalMatch[1]) - 30);
112
+ }
113
+
114
+ const brightMatch = ansi.match(/(?:\[|;)(9[0-7])(?:;|m)/);
115
+ if (brightMatch) {
116
+ return ansi16ToRgb(Number(brightMatch[1]) - 90 + 8);
117
+ }
118
+
119
+ return undefined;
120
+ }
121
+
122
+ function parseForegroundRgbFromAnsi(ansi: string): Rgb | undefined {
123
+ return (
124
+ parseTruecolorAnsi(ansi) ??
125
+ parseAnsi256Foreground(ansi) ??
126
+ parseAnsi16Foreground(ansi)
127
+ );
128
+ }
129
+
130
+ function resolveAccentRgb(theme: { getFgAnsi(name: string): string }): Rgb {
131
+ return (
132
+ parseForegroundRgbFromAnsi(theme.getFgAnsi("accent")) ?? FALLBACK_ACCENT_RGB
133
+ );
134
+ }
135
+
136
+ function buildAccentPalette(accent: Rgb): Rgb[] {
137
+ return Array.from({ length: PALETTE_STEPS }, (_, index) => {
138
+ const progress = index / PALETTE_STEPS;
139
+ const wave = -Math.cos(progress * Math.PI * 2);
140
+
141
+ if (wave < 0) {
142
+ return darkenRgb(accent, PALETTE_MAX_DARKEN * -wave);
143
+ }
144
+
145
+ return lightenRgb(accent, PALETTE_MAX_LIGHTEN * wave);
146
+ });
147
+ }
148
+
149
+ function sampleGradientColor(palette: Rgb[], position: number): Rgb {
150
+ const wrappedPosition = ((position % 1) + 1) % 1;
151
+ const scaledPosition = wrappedPosition * palette.length;
152
+ const baseIndex = Math.floor(scaledPosition) % palette.length;
153
+ const nextIndex = (baseIndex + 1) % palette.length;
154
+ const factor = scaledPosition - Math.floor(scaledPosition);
155
+
156
+ return interpolateRgb(palette[baseIndex]!, palette[nextIndex]!, factor);
157
+ }
158
+
159
+ function renderGradientText(
160
+ text: string,
161
+ palette: Rgb[],
162
+ phase: number,
163
+ ): string {
164
+ const characters = [...text];
165
+ const span = Math.max(characters.length - 1, 1);
166
+
167
+ return characters
168
+ .map((character, index) => {
169
+ if (character === " ") return character;
170
+ // 行内单调暗→亮(1/4 波长),行间相位偏移 → 对角渐变
171
+ const color = sampleGradientColor(
172
+ palette,
173
+ (index / span) * PALETTE_SPAN + phase,
174
+ );
175
+ return applyTruecolor(color, character);
176
+ })
177
+ .join("");
178
+ }
179
+
180
+ function createCenteredBlockLine(
181
+ text: string,
182
+ width: number,
183
+ blockWidth: number,
184
+ ): string {
185
+ const leftPadding = Math.max(0, Math.floor((width - blockWidth) / 2));
186
+ return `${" ".repeat(leftPadding)}${text}`;
187
+ }
188
+
189
+ function createCenteredStyledLine(parts: StyledPart[], width: number): string {
190
+ const rawText = parts.map((part) => part.raw).join("");
191
+ const leftPadding = Math.max(
192
+ 0,
193
+ Math.floor((width - [...rawText].length) / 2),
194
+ );
195
+ const styledText = parts.map((part) => part.styled).join("");
196
+ return `${" ".repeat(leftPadding)}${styledText}`;
197
+ }
198
+
199
+ function fitLineToWidth(line: string, width: number): string {
200
+ if (getVisibleLength(line) <= width) {
201
+ return line;
202
+ }
203
+
204
+ return stripAnsi(line).slice(0, width);
205
+ }
206
+
207
+ function renderLogoLines(width: number, palette: Rgb[]): string[] {
208
+ return LOGO_LINES.map((line, rowIndex) => {
209
+ const phasedLine = renderGradientText(
210
+ line,
211
+ palette,
212
+ rowIndex * LOGO_ROW_PHASE_STEP,
213
+ );
214
+ return createCenteredBlockLine(phasedLine, width, LOGO_BLOCK_WIDTH);
215
+ });
216
+ }
217
+
218
+ // ---- 右侧:原生默认 header 文本(对齐 pi 内置 startup header,按键文本随用户 keybindings 动态渲染) ----
219
+
220
+ function formatKeyPart(part: string): string {
221
+ // 与 pi 内置 keybinding-hints 一致:macOS 上 alt 显示为 option
222
+ return process.platform === "darwin" && part.toLowerCase() === "alt"
223
+ ? "option"
224
+ : part;
225
+ }
226
+
227
+ function formatKeyText(key: string): string {
228
+ return key
229
+ .split("/")
230
+ .map((part) => part.split("+").map(formatKeyPart).join("+"))
231
+ .join("/");
232
+ }
233
+
234
+ function keyText(keybinding: AppKeybinding): string {
235
+ const keys = getKeybindings().getKeys(keybinding);
236
+ return keys.length === 0 ? "" : formatKeyText(keys.join("/"));
237
+ }
238
+
239
+ function renderNativeLines(theme: {
240
+ fg(name: string, text: string): string;
241
+ bold(text: string): string;
242
+ }): string[] {
243
+ // 颜色方案照抄 pi 内置 header:按键 dim、描述 muted、分隔符 muted、版本 dim
244
+ const hint = (keybinding: AppKeybinding, description: string) =>
245
+ theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`);
246
+ const rawHint = (key: string, description: string) =>
247
+ theme.fg("dim", formatKeyText(key)) + theme.fg("muted", ` ${description}`);
248
+
249
+ const logo =
250
+ theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${VERSION}`);
251
+ const compact = [
252
+ hint("app.interrupt", "interrupt"),
253
+ rawHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
254
+ rawHint("/", "commands"),
255
+ rawHint("!", "bash"),
256
+ hint("app.tools.expand", "more"),
257
+ ].join(theme.fg("muted", " · "));
258
+
259
+ return [
260
+ logo,
261
+ compact,
262
+ theme.fg(
263
+ "dim",
264
+ `Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`,
265
+ ),
266
+ "",
267
+ // hero 文案替换原生 "Pi can explain..." 行位置
268
+ renderHeroParts(theme)
269
+ .map((part) => part.styled)
270
+ .join(""),
271
+ ];
272
+ }
273
+
274
+ function renderHeroParts(theme: {
275
+ fg(name: string, text: string): string;
276
+ bold(text: string): string;
277
+ }): StyledPart[] {
278
+ return [
279
+ { raw: HERO_PREFIX, styled: theme.fg("accent", HERO_PREFIX) },
280
+ {
281
+ raw: HERO_HIGHLIGHT,
282
+ styled: theme.bold(theme.fg("mdLink", HERO_HIGHLIGHT)),
283
+ },
284
+ { raw: HERO_SUFFIX, styled: theme.fg("accent", HERO_SUFFIX) },
285
+ ];
286
+ }
287
+
288
+ export function renderHeaderLines(
289
+ width: number,
290
+ theme: {
291
+ getFgAnsi(name: string): string;
292
+ fg(name: string, text: string): string;
293
+ bold(text: string): string;
294
+ },
295
+ ): string[] {
296
+ const accentRgb = resolveAccentRgb(theme);
297
+ const palette = buildAccentPalette(accentRgb);
298
+
299
+ if (width < TWO_COL_MIN_WIDTH) {
300
+ // 窄屏回退:logo + hero 单行垂直堆叠居中
301
+ const logoLines = renderLogoLines(width, palette);
302
+ const heroLine = createCenteredStyledLine(renderHeroParts(theme), width);
303
+ return ["", ...logoLines, "", heroLine, ""].map((line) =>
304
+ fitLineToWidth(line, width),
305
+ );
306
+ }
307
+
308
+ // 双栏:左官方 logo(5 行) 右原生提示(5 行),同高并排,gap 分隔不交叉
309
+ const leftLines = renderLogoLines(LEFT_COLUMN_WIDTH, palette);
310
+ const rightLines = renderNativeLines(theme);
311
+ const rightWidth = width - LEFT_COLUMN_WIDTH - TWO_COL_GAP;
312
+ const padTop = Math.floor((rightLines.length - leftLines.length) / 2);
313
+ const paddedLeft = [
314
+ ...Array.from({ length: padTop }, () => ""),
315
+ ...leftLines,
316
+ ...Array.from(
317
+ { length: rightLines.length - leftLines.length - padTop },
318
+ () => "",
319
+ ),
320
+ ];
321
+
322
+ return paddedLeft.map(
323
+ (line, index) =>
324
+ `${line}${" ".repeat(TWO_COL_GAP)}${fitLineToWidth(rightLines[index] ?? "", rightWidth)}`,
325
+ );
326
+ }
327
+
328
+ /**
329
+ * 按配置应用启动头:on → 自定义 header;off → 恢复官方默认 header。
330
+ * 导出供 /ccstyle 面板在切换开关时实时重应用。
331
+ */
332
+ export function applyStartupHeader(ctx: any): void {
333
+ if (!ctx?.hasUI || typeof ctx.ui?.setHeader !== "function") return;
334
+ if (!config.showStartupHeader) {
335
+ // 恢复官方内置 header(logo + 快捷键提示 + onboarding)。
336
+ ctx.ui.setHeader(undefined);
337
+ return;
338
+ }
339
+ ctx.ui.setHeader((_tui: unknown, theme: any) => ({
340
+ render(width: number): string[] {
341
+ return renderHeaderLines(width, theme);
342
+ },
343
+ invalidate() {},
344
+ }));
345
+ }
346
+
347
+ export default function piStartupHeader(pi: ExtensionAPI) {
348
+ pi.on("session_start", async (_event, ctx) => {
349
+ applyStartupHeader(ctx);
350
+ });
351
+
352
+ pi.on("session_shutdown", async (_event, ctx) => {
353
+ if (!ctx.hasUI) return;
354
+
355
+ ctx.ui.setHeader(undefined);
356
+ });
357
+ }
@@ -0,0 +1,226 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatDuration } from "../../tools/format.ts";
3
+
4
+ const REFRESH_INTERVAL_MS = 1_000;
5
+ /** Elapsed time is only shown once the turn has run this long. */
6
+ const SHOW_TIMER_AFTER_MS = 3_000;
7
+
8
+ function formatCount(value: number): string {
9
+ return new Intl.NumberFormat("en-US").format(value);
10
+ }
11
+
12
+ type ContentBlock = {
13
+ type?: unknown;
14
+ text?: unknown;
15
+ thinkingSignature?: { body?: unknown };
16
+ };
17
+
18
+ type StreamMessage = {
19
+ content?: unknown;
20
+ usage?: { output?: unknown };
21
+ };
22
+
23
+ /** 每个 content index 的可见文本/思考长度;无对应块的 index 保持稀疏洞。 */
24
+ function textBlockLengths(message: StreamMessage): number[] {
25
+ const content = message.content;
26
+ if (!Array.isArray(content)) return [];
27
+ const lengths: number[] = [];
28
+ for (let index = 0; index < content.length; index++) {
29
+ const block = content[index] as ContentBlock;
30
+ if (block?.type === "text" && typeof block.text === "string") {
31
+ lengths[index] = block.text.length;
32
+ } else if (block?.type === "thinking" && block.thinkingSignature?.body) {
33
+ lengths[index] = (block.thinkingSignature.body as string).length;
34
+ }
35
+ }
36
+ return lengths;
37
+ }
38
+
39
+ function outputUsage(message: StreamMessage): number {
40
+ const value = Number(message?.usage?.output);
41
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
42
+ }
43
+
44
+ type WorkingUi = {
45
+ setWorkingMessage(message?: string): void;
46
+ };
47
+
48
+ /**
49
+ * Extend Pi's footer working row while preserving its spinner and "Working...":
50
+ * `⠋ Working... (↓ 1,234 tokens · 12s)`
51
+ *
52
+ * Live tokens use the same chars/4 estimate as pi-claude-code-ui, then switch to
53
+ * provider `usage.output` whenever the stream exposes an actual count.
54
+ */
55
+ export default function (pi: ExtensionAPI): void {
56
+ let turnActive = false;
57
+ let agentStartTime = 0;
58
+ let turnStartTime = 0;
59
+ let responseLength = 0;
60
+ let responseTextBlockLengths: number[] = [];
61
+ let providerOutputTokens = 0;
62
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
63
+ let lastMessage: string | null = null;
64
+ let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
65
+
66
+ function tokenCount(): number {
67
+ return providerOutputTokens || Math.max(0, Math.round(responseLength / 4));
68
+ }
69
+
70
+ function setTextBlockLength(index: number, length: number): void {
71
+ const previous = responseTextBlockLengths[index] ?? 0;
72
+ responseTextBlockLengths[index] = Math.max(0, length);
73
+ responseLength = Math.max(
74
+ 0,
75
+ responseLength + responseTextBlockLengths[index] - previous,
76
+ );
77
+ }
78
+
79
+ function resetResponseTracking(message?: StreamMessage): void {
80
+ responseTextBlockLengths = message ? textBlockLengths(message) : [];
81
+ responseLength = responseTextBlockLengths.reduce(
82
+ (sum, length) => sum + length,
83
+ 0,
84
+ );
85
+ providerOutputTokens = message ? outputUsage(message) : 0;
86
+ }
87
+
88
+ function updateProviderUsage(message: StreamMessage): void {
89
+ const output = outputUsage(message);
90
+ if (output > 0) providerOutputTokens = output;
91
+ }
92
+
93
+ function buildWorkingMessage(): string {
94
+ const elapsed = Date.now() - (agentStartTime || turnStartTime);
95
+ const tokens = tokenCount();
96
+ const parts: string[] = [];
97
+ if (tokens > 0) parts.push(`↓ ${formatCount(tokens)} tokens`);
98
+ if (elapsed >= SHOW_TIMER_AFTER_MS || tokens > 0) {
99
+ // formatDuration 低于 1 秒返回 "",此处回退 "0s" 保持计时器连续跳动。
100
+ parts.push(formatDuration(elapsed) || "0s");
101
+ }
102
+ return parts.length ? `Working... (${parts.join(" · ")})` : "";
103
+ }
104
+
105
+ function restoreDefaultWorkingMessage(): void {
106
+ lastMessage = null;
107
+ if (!activeCtx?.hasUI) return;
108
+ try {
109
+ activeCtx.ui?.setWorkingMessage();
110
+ } catch {
111
+ // Noop when the TUI is unavailable.
112
+ }
113
+ }
114
+
115
+ function syncWorkingMessage(force = false): void {
116
+ if (!activeCtx?.hasUI) return;
117
+ const next = buildWorkingMessage();
118
+ if (!next) {
119
+ if (force) restoreDefaultWorkingMessage();
120
+ return;
121
+ }
122
+ if (!force && next === lastMessage) return;
123
+ lastMessage = next;
124
+ try {
125
+ activeCtx.ui?.setWorkingMessage(next);
126
+ } catch {
127
+ // Noop when the TUI is unavailable.
128
+ }
129
+ }
130
+
131
+ function scheduleRefreshTick(): void {
132
+ if (!turnActive || refreshTimer) return;
133
+ refreshTimer = setTimeout(() => {
134
+ refreshTimer = null;
135
+ try {
136
+ syncWorkingMessage();
137
+ } finally {
138
+ scheduleRefreshTick();
139
+ }
140
+ }, REFRESH_INTERVAL_MS);
141
+ refreshTimer.unref?.();
142
+ }
143
+
144
+ function stopRefreshLoop(): void {
145
+ if (!refreshTimer) return;
146
+ clearTimeout(refreshTimer);
147
+ refreshTimer = null;
148
+ }
149
+
150
+ function clearDisplay(): void {
151
+ stopRefreshLoop();
152
+ agentStartTime = 0;
153
+ turnStartTime = 0;
154
+ resetResponseTracking();
155
+ restoreDefaultWorkingMessage();
156
+ }
157
+
158
+ pi.on("before_agent_start", async () => {
159
+ if (!agentStartTime) agentStartTime = Date.now();
160
+ });
161
+
162
+ pi.on("turn_start", async (_event, ctx) => {
163
+ turnActive = true;
164
+ activeCtx = ctx;
165
+ turnStartTime = Date.now();
166
+ if (!agentStartTime) agentStartTime = turnStartTime;
167
+ resetResponseTracking();
168
+ syncWorkingMessage(true);
169
+ scheduleRefreshTick();
170
+ });
171
+
172
+ pi.on("message_update", async (event, ctx) => {
173
+ activeCtx = ctx;
174
+ const evt = event?.assistantMessageEvent;
175
+ if (!evt) return;
176
+
177
+ if (evt.type === "start") {
178
+ resetResponseTracking(evt.partial);
179
+ } else if (evt.type === "thinking_start" || evt.type === "text_start") {
180
+ setTextBlockLength(evt.contentIndex, 0);
181
+ updateProviderUsage(evt.partial);
182
+ } else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
183
+ const add = typeof evt.delta === "string" ? evt.delta.length : 0;
184
+ setTextBlockLength(
185
+ evt.contentIndex,
186
+ (responseTextBlockLengths[evt.contentIndex] ?? 0) + add,
187
+ );
188
+ updateProviderUsage(evt.partial);
189
+ } else if (evt.type === "text_end") {
190
+ setTextBlockLength(
191
+ evt.contentIndex,
192
+ typeof evt.content === "string" ? evt.content.length : 0,
193
+ );
194
+ updateProviderUsage(evt.partial);
195
+ } else if (evt.type === "done") {
196
+ resetResponseTracking(evt.message);
197
+ } else if (evt.type === "error") {
198
+ resetResponseTracking(evt.error);
199
+ } else {
200
+ updateProviderUsage(evt.partial);
201
+ }
202
+
203
+ syncWorkingMessage();
204
+ scheduleRefreshTick();
205
+ });
206
+
207
+ pi.on("turn_end", async (_event, ctx) => {
208
+ turnActive = false;
209
+ activeCtx = ctx;
210
+ stopRefreshLoop();
211
+ resetResponseTracking();
212
+ // No completion message: return immediately to Pi's default idle state.
213
+ restoreDefaultWorkingMessage();
214
+ });
215
+
216
+ pi.on("agent_end", async () => {
217
+ turnActive = false;
218
+ clearDisplay();
219
+ });
220
+
221
+ pi.on("session_shutdown", async () => {
222
+ turnActive = false;
223
+ clearDisplay();
224
+ activeCtx = null;
225
+ });
226
+ }
@@ -0,0 +1,52 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { showOneUiPanel } from "./app/panel.ts";
3
+ import registerShell, {
4
+ type ShellExtensionOptions,
5
+ type ShellRuntimeController,
6
+ } from "./shell/index.ts";
7
+ import registerTranscript, {
8
+ type TranscriptExtensionOptions,
9
+ type TranscriptRuntimeController,
10
+ } from "./transcript/index.ts";
11
+
12
+ type Controllers = {
13
+ shell?: ShellRuntimeController;
14
+ renderer?: TranscriptRuntimeController;
15
+ };
16
+
17
+ function registerOneUiCommand(
18
+ pi: ExtensionAPI,
19
+ controllers: Controllers,
20
+ ): void {
21
+ pi.registerCommand("oneui", {
22
+ description: "Open pi-one-ui settings",
23
+ handler: async (_args, ctx) => {
24
+ await showOneUiPanel(ctx, controllers);
25
+ },
26
+ });
27
+ }
28
+
29
+ export default function (pi: ExtensionAPI): void {
30
+ let shellController: ShellRuntimeController | undefined;
31
+ let rendererController: TranscriptRuntimeController | undefined;
32
+ const shellOptions: ShellExtensionOptions = {
33
+ registerCommand: false,
34
+ onRuntimeController: (controller) => {
35
+ shellController = controller;
36
+ },
37
+ };
38
+ const rendererOptions: TranscriptExtensionOptions = {
39
+ onRuntimeController: (controller) => {
40
+ rendererController = controller;
41
+ },
42
+ };
43
+
44
+ // One package, one management command. The internal owners remain explicit:
45
+ // Shell owns the terminal chrome; transcript owns tool and message rendering.
46
+ registerShell(pi, shellOptions);
47
+ registerTranscript(pi, rendererOptions);
48
+ registerOneUiCommand(pi, {
49
+ shell: shellController,
50
+ renderer: rendererController,
51
+ });
52
+ }