gsd-pi 2.28.0-dev.a3afd44 → 2.28.0-dev.a8d3050

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 (53) hide show
  1. package/dist/resources/extensions/gsd/commands-handlers.ts +1 -9
  2. package/dist/resources/extensions/gsd/commands-prefs-wizard.ts +14 -22
  3. package/dist/resources/extensions/gsd/commands.ts +1 -7
  4. package/dist/resources/extensions/gsd/index.ts +2 -1
  5. package/dist/resources/extensions/gsd/json-persistence.ts +52 -0
  6. package/dist/resources/extensions/gsd/metrics.ts +17 -31
  7. package/dist/resources/extensions/gsd/paths.ts +0 -8
  8. package/dist/resources/extensions/gsd/routing-history.ts +13 -17
  9. package/dist/resources/extensions/gsd/tests/gsd-inspect.test.ts +1 -1
  10. package/dist/resources/extensions/gsd/unit-runtime.ts +16 -13
  11. package/dist/resources/extensions/remote-questions/discord-adapter.ts +2 -2
  12. package/dist/resources/extensions/remote-questions/notify.ts +1 -2
  13. package/dist/resources/extensions/remote-questions/slack-adapter.ts +1 -2
  14. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +1 -2
  15. package/dist/resources/extensions/remote-questions/types.ts +3 -0
  16. package/dist/resources/extensions/shared/mod.ts +3 -0
  17. package/package.json +4 -1
  18. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +3 -0
  19. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  20. package/packages/pi-coding-agent/dist/core/settings-manager.js +8 -0
  21. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  22. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  23. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +4 -1
  24. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  25. package/packages/pi-coding-agent/src/core/settings-manager.ts +11 -0
  26. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +4 -1
  27. package/packages/pi-tui/dist/autocomplete.d.ts +3 -0
  28. package/packages/pi-tui/dist/autocomplete.d.ts.map +1 -1
  29. package/packages/pi-tui/dist/autocomplete.js +14 -0
  30. package/packages/pi-tui/dist/autocomplete.js.map +1 -1
  31. package/packages/pi-tui/src/autocomplete.ts +19 -1
  32. package/src/resources/extensions/gsd/commands-handlers.ts +1 -9
  33. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +14 -22
  34. package/src/resources/extensions/gsd/commands.ts +1 -7
  35. package/src/resources/extensions/gsd/index.ts +2 -1
  36. package/src/resources/extensions/gsd/json-persistence.ts +52 -0
  37. package/src/resources/extensions/gsd/metrics.ts +17 -31
  38. package/src/resources/extensions/gsd/paths.ts +0 -8
  39. package/src/resources/extensions/gsd/routing-history.ts +13 -17
  40. package/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +1 -1
  41. package/src/resources/extensions/gsd/unit-runtime.ts +16 -13
  42. package/src/resources/extensions/remote-questions/discord-adapter.ts +2 -2
  43. package/src/resources/extensions/remote-questions/notify.ts +1 -2
  44. package/src/resources/extensions/remote-questions/slack-adapter.ts +1 -2
  45. package/src/resources/extensions/remote-questions/telegram-adapter.ts +1 -2
  46. package/src/resources/extensions/remote-questions/types.ts +3 -0
  47. package/src/resources/extensions/shared/mod.ts +3 -0
  48. package/dist/resources/extensions/gsd/preferences-hooks.ts +0 -10
  49. package/dist/resources/extensions/shared/progress-widget.ts +0 -282
  50. package/dist/resources/extensions/shared/thinking-widget.ts +0 -107
  51. package/src/resources/extensions/gsd/preferences-hooks.ts +0 -10
  52. package/src/resources/extensions/shared/progress-widget.ts +0 -282
  53. package/src/resources/extensions/shared/thinking-widget.ts +0 -107
@@ -2,10 +2,10 @@
2
2
  // Tracks success/failure per tier per unit-type pattern to improve
3
3
  // classification accuracy over time.
4
4
 
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
5
  import { join } from "node:path";
7
6
  import { gsdRoot } from "./paths.js";
8
7
  import type { ComplexityTier } from "./types.js";
8
+ import { loadJsonFile, saveJsonFile } from "./json-persistence.js";
9
9
 
10
10
  // ─── Types ───────────────────────────────────────────────────────────────────
11
11
 
@@ -267,24 +267,20 @@ function historyPath(base: string): string {
267
267
  return join(gsdRoot(base), HISTORY_FILE);
268
268
  }
269
269
 
270
+ function isRoutingHistoryData(data: unknown): data is RoutingHistoryData {
271
+ return (
272
+ typeof data === "object" &&
273
+ data !== null &&
274
+ (data as RoutingHistoryData).version === 1 &&
275
+ typeof (data as RoutingHistoryData).patterns === "object" &&
276
+ (data as RoutingHistoryData).patterns !== null
277
+ );
278
+ }
279
+
270
280
  function loadHistory(base: string): RoutingHistoryData {
271
- try {
272
- const raw = readFileSync(historyPath(base), "utf-8");
273
- const parsed = JSON.parse(raw);
274
- if (parsed.version === 1 && parsed.patterns) {
275
- return parsed as RoutingHistoryData;
276
- }
277
- } catch {
278
- // File doesn't exist or is corrupt — start fresh
279
- }
280
- return createEmptyHistory();
281
+ return loadJsonFile(historyPath(base), isRoutingHistoryData, createEmptyHistory);
281
282
  }
282
283
 
283
284
  function saveHistory(base: string, data: RoutingHistoryData): void {
284
- try {
285
- mkdirSync(gsdRoot(base), { recursive: true });
286
- writeFileSync(historyPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8");
287
- } catch {
288
- // Non-fatal — don't let history failures break auto-mode
289
- }
285
+ saveJsonFile(historyPath(base), data);
290
286
  }
@@ -3,7 +3,7 @@
3
3
  // Tests the pure formatInspectOutput function with known data.
4
4
 
5
5
  import { createTestContext } from './test-helpers.ts';
6
- import { formatInspectOutput, type InspectData } from '../commands.ts';
6
+ import { formatInspectOutput, type InspectData } from '../commands-inspect.ts';
7
7
 
8
8
  const { assertEq, assertTrue, assertMatch, report } = createTestContext();
9
9
 
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  gsdRoot,
@@ -8,6 +8,7 @@ import {
8
8
  resolveTaskFile,
9
9
  } from "./paths.js";
10
10
  import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
11
+ import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
11
12
 
12
13
  export type UnitRuntimePhase =
13
14
  | "dispatched"
@@ -46,13 +47,23 @@ export interface AutoUnitRuntimeRecord {
46
47
  lastRecoveryReason?: "idle" | "hard";
47
48
  }
48
49
 
50
+ function isAutoUnitRuntimeRecord(data: unknown): data is AutoUnitRuntimeRecord {
51
+ return (
52
+ typeof data === "object" &&
53
+ data !== null &&
54
+ (data as AutoUnitRuntimeRecord).version === 1 &&
55
+ typeof (data as AutoUnitRuntimeRecord).unitType === "string" &&
56
+ typeof (data as AutoUnitRuntimeRecord).unitId === "string"
57
+ );
58
+ }
59
+
49
60
  function runtimeDir(basePath: string): string {
50
61
  return join(gsdRoot(basePath), "runtime", "units");
51
62
  }
52
63
 
53
64
  function runtimePath(basePath: string, unitType: string, unitId: string): string {
54
- const sanitizedUnitType = unitType.replace(/[\/]/g, "-");
55
- const sanitizedUnitId = unitId.replace(/[\/]/g, "-");
65
+ const sanitizedUnitType = unitType.replace(/[^a-zA-Z0-9._-]+/g, "-");
66
+ const sanitizedUnitId = unitId.replace(/[^a-zA-Z0-9._-]+/g, "-");
56
67
  return join(runtimeDir(basePath), `${sanitizedUnitType}-${sanitizedUnitId}.json`);
57
68
  }
58
69
 
@@ -63,8 +74,6 @@ export function writeUnitRuntimeRecord(
63
74
  startedAt: number,
64
75
  updates: Partial<AutoUnitRuntimeRecord> = {},
65
76
  ): AutoUnitRuntimeRecord {
66
- const dir = runtimeDir(basePath);
67
- mkdirSync(dir, { recursive: true });
68
77
  const path = runtimePath(basePath, unitType, unitId);
69
78
  const prev = readUnitRuntimeRecord(basePath, unitType, unitId);
70
79
  const next: AutoUnitRuntimeRecord = {
@@ -84,18 +93,12 @@ export function writeUnitRuntimeRecord(
84
93
  recoveryAttempts: updates.recoveryAttempts ?? prev?.recoveryAttempts ?? 0,
85
94
  lastRecoveryReason: updates.lastRecoveryReason ?? prev?.lastRecoveryReason,
86
95
  };
87
- writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8");
96
+ saveJsonFile(path, next);
88
97
  return next;
89
98
  }
90
99
 
91
100
  export function readUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): AutoUnitRuntimeRecord | null {
92
- const path = runtimePath(basePath, unitType, unitId);
93
- if (!existsSync(path)) return null;
94
- try {
95
- return JSON.parse(readFileSync(path, "utf-8")) as AutoUnitRuntimeRecord;
96
- } catch {
97
- return null;
98
- }
101
+ return loadJsonFileOrNull(runtimePath(basePath, unitType, unitId), isAutoUnitRuntimeRecord);
99
102
  }
100
103
 
101
104
  export function clearUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): void {
@@ -2,11 +2,11 @@
2
2
  * Remote Questions — Discord adapter
3
3
  */
4
4
 
5
- import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
5
+ import { PER_REQUEST_TIMEOUT_MS, type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js";
6
6
  import { formatForDiscord, parseDiscordResponse, DISCORD_NUMBER_EMOJIS } from "./format.js";
7
7
 
8
8
  const DISCORD_API = "https://discord.com/api/v10";
9
- const PER_REQUEST_TIMEOUT_MS = 15_000;
9
+
10
10
  export class DiscordAdapter implements ChannelAdapter {
11
11
  readonly name = "discord" as const;
12
12
  private botUserId: string | null = null;
@@ -9,8 +9,7 @@
9
9
 
10
10
  import { resolveRemoteConfig } from "./config.js";
11
11
  import type { ResolvedConfig } from "./config.js";
12
-
13
- const PER_REQUEST_TIMEOUT_MS = 15_000;
12
+ import { PER_REQUEST_TIMEOUT_MS } from "./types.js";
14
13
 
15
14
  /**
16
15
  * Send a one-way notification to the configured remote channel.
@@ -2,11 +2,10 @@
2
2
  * Remote Questions — Slack adapter
3
3
  */
4
4
 
5
- import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
5
+ import { PER_REQUEST_TIMEOUT_MS, type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js";
6
6
  import { formatForSlack, parseSlackReply, parseSlackReactionResponse, SLACK_NUMBER_REACTION_NAMES } from "./format.js";
7
7
 
8
8
  const SLACK_API = "https://slack.com/api";
9
- const PER_REQUEST_TIMEOUT_MS = 15_000;
10
9
  const SLACK_ACK_REACTION = "white_check_mark";
11
10
 
12
11
  export class SlackAdapter implements ChannelAdapter {
@@ -2,11 +2,10 @@
2
2
  * Remote Questions — Telegram adapter
3
3
  */
4
4
 
5
- import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
5
+ import { PER_REQUEST_TIMEOUT_MS, type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js";
6
6
  import { formatForTelegram, parseTelegramResponse } from "./format.js";
7
7
 
8
8
  const TELEGRAM_API = "https://api.telegram.org";
9
- const PER_REQUEST_TIMEOUT_MS = 15_000;
10
9
 
11
10
  export class TelegramAdapter implements ChannelAdapter {
12
11
  readonly name = "telegram" as const;
@@ -2,6 +2,9 @@
2
2
  * Remote Questions — shared types
3
3
  */
4
4
 
5
+ /** Timeout applied to every outbound HTTP request across all channel adapters. */
6
+ export const PER_REQUEST_TIMEOUT_MS = 15_000;
7
+
5
8
  export type RemoteChannel = "slack" | "discord" | "telegram";
6
9
 
7
10
  export interface RemoteQuestionOption {
@@ -28,3 +28,6 @@ export { showInterviewRound } from "./interview-ui.js";
28
28
  export type { Question, QuestionOption, RoundResult } from "./interview-ui.js";
29
29
  export { showNextAction } from "./next-action-ui.js";
30
30
  export { showConfirm } from "./confirm-ui.js";
31
+ export { sanitizeError } from "./sanitize.js";
32
+ export { formatDateShort, truncateWithEllipsis } from "./format-utils.js";
33
+ export { splitFrontmatter, parseFrontmatterMap } from "./frontmatter.js";
@@ -1,10 +0,0 @@
1
- /**
2
- * Focused re-export: hook-related preferences.
3
- *
4
- * Consumers that only need hook resolution can import from this module
5
- * instead of the monolithic preferences.ts, reducing coupling surface.
6
- */
7
- export {
8
- resolvePostUnitHooks,
9
- resolvePreDispatchHooks,
10
- } from "./preferences.js";
@@ -1,282 +0,0 @@
1
- /**
2
- * Shared persistent progress/status panel widget.
3
- *
4
- * Renders an ordered list of progress items with status glyphs, optional
5
- * badge, subtitle, metadata, and footer hints. Supports pulse animation
6
- * for active items during agent execution.
7
- *
8
- * Usage:
9
- *
10
- * import { createProgressPanel } from "./shared/progress-widget.js";
11
- *
12
- * const panel = createProgressPanel(ctx.ui, {
13
- * widgetKey: "workflow",
14
- * statusKey: "workflow",
15
- * statusPrefix: "wf",
16
- * });
17
- *
18
- * panel.update(model); // render/re-render with new model
19
- * panel.startPulse(); // animate active items
20
- * panel.stopPulse(); // stop animation
21
- * panel.dispose(); // remove widget and status
22
- */
23
-
24
- import type { ExtensionUIContext, Theme } from "@gsd/pi-coding-agent";
25
- import type { TUI } from "@gsd/pi-tui";
26
- import { makeUI, type ProgressStatus } from "./ui.js";
27
-
28
- // ─── Exported types ───────────────────────────────────────────────────────────
29
-
30
- export type ProgressItemStatus = ProgressStatus;
31
-
32
- export interface ProgressItem {
33
- /** Display label */
34
- label: string;
35
- /** Drives glyph and color */
36
- status: ProgressItemStatus;
37
- /** Optional text after label — e.g. artifact type, task ID */
38
- detail?: string;
39
- /** Optional secondary line below item — e.g. "waiting for /workflow-continue" */
40
- annotation?: string;
41
- }
42
-
43
- export interface ProgressPanelModel {
44
- /** Panel title */
45
- title: string;
46
- /** Optional badge next to title — e.g. "RUNNING", "PAUSED" */
47
- badge?: string;
48
- /** Badge color control — maps to ProgressItemStatus color */
49
- badgeStatus?: ProgressItemStatus;
50
- /** Optional subtitle lines below title */
51
- subtitle?: string[];
52
- /** Ordered progress items */
53
- items: ProgressItem[];
54
- /** Optional metadata lines below items */
55
- meta?: string[];
56
- /** Optional footer hint strings */
57
- hints?: string[];
58
- }
59
-
60
- export interface ProgressPanelOptions {
61
- /**
62
- * Widget key used with ctx.ui.setWidget(...).
63
- * Must be unique per extension.
64
- */
65
- widgetKey: string;
66
- /**
67
- * Status key used with ctx.ui.setStatus(...).
68
- * Must be unique per extension.
69
- */
70
- statusKey: string;
71
- /**
72
- * Short prefix for footer status text.
73
- * Example: "wf" produces "wf:2/3 RUNNING"
74
- */
75
- statusPrefix: string;
76
- }
77
-
78
- export interface ProgressPanel {
79
- /** Update the widget with a new model. Triggers re-render. */
80
- update(model: ProgressPanelModel): void;
81
- /** Start pulsing items with status "active". */
82
- startPulse(): void;
83
- /** Stop pulsing. Active items render at full brightness. */
84
- stopPulse(): void;
85
- /** Remove the widget and status from the UI. */
86
- dispose(): void;
87
- }
88
-
89
- // ─── Internal constants ───────────────────────────────────────────────────────
90
-
91
- const PULSE_INTERVAL_MS = 500;
92
-
93
- // ─── Implementation ───────────────────────────────────────────────────────────
94
-
95
- /**
96
- * Create and register a persistent progress widget.
97
- *
98
- * @param ui The `ctx.ui` object from ExtensionContext or ExtensionCommandContext
99
- * @param options Widget key, status key, and status prefix
100
- * @returns ProgressPanel controller
101
- */
102
- export function createProgressPanel(
103
- ui: ExtensionUIContext,
104
- options: ProgressPanelOptions,
105
- ): ProgressPanel {
106
- const { widgetKey, statusKey, statusPrefix } = options;
107
-
108
- // ── Internal state ────────────────────────────────────────────────────────
109
-
110
- let currentModel: ProgressPanelModel | null = null;
111
- let stateVersion = 0;
112
- let cachedLines: string[] | undefined;
113
- let cachedWidth: number | undefined;
114
- let cachedVersion = -1;
115
- let pulseBright = true;
116
- let pulseTimer: ReturnType<typeof setInterval> | null = null;
117
- let widgetRef: { invalidate: () => void; requestRender: () => void } | null = null;
118
-
119
- // ── Footer status ─────────────────────────────────────────────────────────
120
-
121
- function updateFooterStatus(): void {
122
- if (!currentModel) return;
123
- const { items, badge } = currentModel;
124
- const total = items.length;
125
- let current = 0;
126
-
127
- // Find first active item index (1-based)
128
- const activeIdx = items.findIndex((it) => it.status === "active");
129
- if (activeIdx >= 0) {
130
- current = activeIdx + 1;
131
- } else {
132
- // Count done items + 1
133
- current = items.filter((it) => it.status === "done").length + 1;
134
- }
135
- if (current > total) current = total;
136
-
137
- const badgePart = badge ? ` ${badge}` : "";
138
- const statusText = ui.theme.fg("accent", `${statusPrefix}:${current}/${total}${badgePart}`);
139
- ui.setStatus(statusKey, statusText);
140
- }
141
-
142
- // ── Render function ───────────────────────────────────────────────────────
143
-
144
- function renderPanel(width: number, theme: Theme): string[] {
145
- // Version-based cache check
146
- if (cachedLines && cachedWidth === width && cachedVersion === stateVersion) {
147
- return cachedLines;
148
- }
149
-
150
- if (!currentModel) return [];
151
-
152
- const uiHelper = makeUI(theme, width);
153
- const lines: string[] = [];
154
- const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
155
- const model = currentModel;
156
-
157
- // 1. Top bar
158
- push(uiHelper.bar());
159
-
160
- // 2. Title area — title with optional inline badge
161
- if (model.badge && model.badgeStatus) {
162
- const titleText = uiHelper.header(model.title)[0];
163
- const badgeGlyph = uiHelper.statusGlyph(model.badgeStatus);
164
- const badgeLabel = uiHelper.statusBadge(model.badge, model.badgeStatus)[0];
165
- lines.push(`${titleText} ${badgeGlyph} ${badgeLabel.trimStart()}`);
166
- } else {
167
- push(uiHelper.header(model.title));
168
- }
169
-
170
- // 3. Subtitle
171
- if (model.subtitle?.length) {
172
- for (const line of model.subtitle) {
173
- push(uiHelper.meta(line));
174
- }
175
- }
176
-
177
- // 4. Blank line
178
- push(uiHelper.blank());
179
-
180
- // 5. Items
181
- for (const item of model.items) {
182
- // Pulse: when pulseBright is false and item is active, render as pending (dimmed)
183
- const renderStatus: ProgressStatus = (!pulseBright && item.status === "active")
184
- ? "pending"
185
- : item.status;
186
-
187
- push(uiHelper.progressItem(item.label, renderStatus, {
188
- detail: item.detail,
189
- emphasized: item.status === "active",
190
- }));
191
-
192
- if (item.annotation) {
193
- push(uiHelper.progressAnnotation(item.annotation));
194
- }
195
- }
196
-
197
- // 6. Blank line (if meta or hints follow)
198
- if (model.meta?.length || model.hints?.length) {
199
- push(uiHelper.blank());
200
- }
201
-
202
- // 7. Meta
203
- if (model.meta?.length) {
204
- for (const line of model.meta) {
205
- push(uiHelper.meta(line));
206
- }
207
- }
208
-
209
- // 8. Hints
210
- if (model.hints?.length) {
211
- push(uiHelper.hints(model.hints));
212
- }
213
-
214
- // 9. Bottom bar
215
- push(uiHelper.bar());
216
-
217
- cachedLines = lines;
218
- cachedWidth = width;
219
- cachedVersion = stateVersion;
220
- return lines;
221
- }
222
-
223
- // ── Register widget ───────────────────────────────────────────────────────
224
-
225
- ui.setWidget(widgetKey, (tui: TUI, theme: Theme) => {
226
- widgetRef = {
227
- invalidate: () => { cachedLines = undefined; },
228
- requestRender: () => tui.requestRender(),
229
- };
230
-
231
- return {
232
- render(width: number): string[] {
233
- return renderPanel(width, theme);
234
- },
235
- invalidate() {
236
- cachedLines = undefined;
237
- },
238
- };
239
- });
240
-
241
- // ── Controller ────────────────────────────────────────────────────────────
242
-
243
- return {
244
- update(model: ProgressPanelModel): void {
245
- currentModel = model;
246
- stateVersion++;
247
- cachedLines = undefined;
248
- updateFooterStatus();
249
- if (widgetRef) widgetRef.requestRender();
250
- },
251
-
252
- startPulse(): void {
253
- if (pulseTimer) return; // already pulsing
254
- pulseTimer = setInterval(() => {
255
- pulseBright = !pulseBright;
256
- cachedLines = undefined;
257
- if (widgetRef) widgetRef.requestRender();
258
- }, PULSE_INTERVAL_MS);
259
- },
260
-
261
- stopPulse(): void {
262
- if (pulseTimer) {
263
- clearInterval(pulseTimer);
264
- pulseTimer = null;
265
- }
266
- pulseBright = true;
267
- cachedLines = undefined;
268
- if (widgetRef) widgetRef.requestRender();
269
- },
270
-
271
- dispose(): void {
272
- if (pulseTimer) {
273
- clearInterval(pulseTimer);
274
- pulseTimer = null;
275
- }
276
- ui.setWidget(widgetKey, undefined);
277
- ui.setStatus(statusKey, undefined);
278
- currentModel = null;
279
- widgetRef = null;
280
- },
281
- };
282
- }
@@ -1,107 +0,0 @@
1
- /**
2
- * Shared thinking/spinner widget.
3
- *
4
- * Shows an animated spinner with a label and an optional live-preview of
5
- * streamed text (e.g. LLM output) while a background operation is running.
6
- *
7
- * Usage:
8
- *
9
- * import { showThinkingWidget } from "./shared/thinking-widget.js";
10
- *
11
- * const widget = showThinkingWidget("Generating questions…", ctx);
12
- *
13
- * // Optionally stream partial text into the preview line:
14
- * widget.setText(partialLlmOutput);
15
- *
16
- * // Always dispose when done — removes the widget from the UI:
17
- * widget.dispose();
18
- *
19
- * Each call gets a unique widget key derived from a monotonic counter, so
20
- * multiple widgets can safely coexist without key collisions.
21
- */
22
-
23
- import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
24
- import { type Theme } from "@gsd/pi-coding-agent";
25
- import { truncateToWidth, type TUI } from "@gsd/pi-tui";
26
-
27
- // ─── Public API ───────────────────────────────────────────────────────────────
28
-
29
- export interface ThinkingWidget {
30
- /**
31
- * Update the streamed-text preview line.
32
- * Pass the full accumulated text — the widget trims and previews the tail.
33
- */
34
- setText(text: string): void;
35
- /** Remove the widget from the UI. Always call this when the operation completes. */
36
- dispose(): void;
37
- }
38
-
39
- // ─── Internal constants ───────────────────────────────────────────────────────
40
-
41
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
42
- const SPINNER_INTERVAL_MS = 80;
43
- const PREVIEW_MAX_CHARS = 120;
44
-
45
- let widgetCounter = 0;
46
-
47
- // ─── Implementation ───────────────────────────────────────────────────────────
48
-
49
- /**
50
- * Show an animated thinking spinner as a TUI widget.
51
- *
52
- * @param label Short description of what is happening, e.g. "Writing PROJECT.md…"
53
- * @param ctx Extension command context
54
- * @returns Handle with setText() and dispose()
55
- */
56
- export function showThinkingWidget(label: string, ctx: ExtensionCommandContext): ThinkingWidget {
57
- const widgetKey = `thinking-widget-${++widgetCounter}`;
58
-
59
- let streamedText = "";
60
- let widgetRef: { invalidate: () => void; requestRender: () => void } | null = null;
61
-
62
- ctx.ui.setWidget(widgetKey, (tui: TUI, theme: Theme) => {
63
- let frame = 0;
64
- let cachedLines: string[] | undefined;
65
-
66
- const interval = setInterval(() => {
67
- frame = (frame + 1) % SPINNER_FRAMES.length;
68
- cachedLines = undefined;
69
- tui.requestRender();
70
- }, SPINNER_INTERVAL_MS);
71
-
72
- widgetRef = {
73
- invalidate: () => { cachedLines = undefined; },
74
- requestRender: () => tui.requestRender(),
75
- };
76
-
77
- return {
78
- render(width: number): string[] {
79
- if (cachedLines) return cachedLines;
80
- const spinner = theme.fg("accent", SPINNER_FRAMES[frame]);
81
- const lines: string[] = [];
82
- lines.push(truncateToWidth(` ${spinner} ${theme.fg("muted", label)}`, width));
83
- if (streamedText) {
84
- const preview = streamedText.replace(/\s+/g, " ").trim().slice(-PREVIEW_MAX_CHARS);
85
- lines.push(truncateToWidth(` ${theme.fg("dim", preview)}`, width));
86
- }
87
- cachedLines = lines;
88
- return lines;
89
- },
90
- invalidate() { cachedLines = undefined; },
91
- dispose() { clearInterval(interval); },
92
- };
93
- });
94
-
95
- return {
96
- setText(text: string) {
97
- streamedText = text;
98
- if (widgetRef) {
99
- widgetRef.invalidate();
100
- widgetRef.requestRender();
101
- }
102
- },
103
- dispose() {
104
- ctx.ui.setWidget(widgetKey, undefined);
105
- },
106
- };
107
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * Focused re-export: hook-related preferences.
3
- *
4
- * Consumers that only need hook resolution can import from this module
5
- * instead of the monolithic preferences.ts, reducing coupling surface.
6
- */
7
- export {
8
- resolvePostUnitHooks,
9
- resolvePreDispatchHooks,
10
- } from "./preferences.js";