@ramarivera/coding-buddy 0.9.0 → 0.9.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.
@@ -1,19 +1,13 @@
1
1
  import type { OmpBuddyUiContext } from "./context.ts";
2
2
  import type { Achievement } from "../../core/achievements.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
- import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
4
+ import { renderAchievementsSummary } from "./renderers.ts";
5
5
  import { OmpBuddyStorage } from "./storage.ts";
6
- import { getWidgetWidth, subscribeToWidgetResize } from "../shared/widget-layout.ts";
6
+ import { BuddyWidget } from "../shared/widget-layout.ts";
7
7
 
8
8
  export class OmpBuddyUI {
9
9
  private readonly timers: Array<{ timer: Timer; clear: (timer: Timer) => void }> = [];
10
- private resizeUnsubscribe: (() => void) | null = null;
11
- private rendered: {
12
- ctx: OmpBuddyUiContext;
13
- companion: Companion;
14
- reaction: ReactionState | null;
15
- achievements: Achievement[];
16
- } | null = null;
10
+ private readonly buddy = new BuddyWidget();
17
11
 
18
12
  constructor(private readonly storage: OmpBuddyStorage) {}
19
13
 
@@ -29,9 +23,7 @@ export class OmpBuddyUI {
29
23
 
30
24
  dispose(): void {
31
25
  this.cancelTimers();
32
- this.resizeUnsubscribe?.();
33
- this.resizeUnsubscribe = null;
34
- this.rendered = null;
26
+ this.buddy.dispose();
35
27
  }
36
28
 
37
29
  refresh(
@@ -42,12 +34,15 @@ export class OmpBuddyUI {
42
34
  ): void {
43
35
  const currentReaction = reaction ?? null;
44
36
  const muted = this.storage.isMuted();
45
- this.rendered = { ctx, companion, reaction: currentReaction, achievements: [...achievements] };
46
- this.subscribeToResize();
37
+ const scheduler = getScheduler(ctx);
38
+
47
39
  ctx.ui.setStatus("buddy", undefined);
48
- ctx.ui.setWidget(
49
- "buddy",
50
- renderBuddyWidget(companion, muted ? null : currentReaction, achievements, getWidgetWidth()),
40
+ this.buddy.refresh(
41
+ (lines) => ctx.ui.setWidget("buddy", lines),
42
+ companion,
43
+ muted ? null : currentReaction,
44
+ [...achievements],
45
+ scheduler ?? undefined,
51
46
  );
52
47
 
53
48
  this.cancelTimers();
@@ -55,8 +50,6 @@ export class OmpBuddyUI {
55
50
 
56
51
  const ttl = this.storage.loadConfig().reactionTTL;
57
52
  if (ttl <= 0) return;
58
-
59
- const scheduler = getScheduler(ctx);
60
53
  if (!scheduler) return;
61
54
 
62
55
  const reactionTimestamp = currentReaction.timestamp;
@@ -83,21 +76,10 @@ export class OmpBuddyUI {
83
76
  unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
84
77
  remaining: Achievement[],
85
78
  ): void {
86
- this.rendered = null;
79
+ this.buddy.clear();
87
80
  ctx.ui.setWidget("buddy", renderAchievementsSummary(unlocked, remaining));
88
81
  ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
89
82
  }
90
-
91
- private subscribeToResize(): void {
92
- if (this.resizeUnsubscribe) return;
93
- const resizeHandler = () => {
94
- if (!this.rendered) return;
95
- const { ctx, companion, reaction, achievements } = this.rendered;
96
- const muted = this.storage.isMuted();
97
- ctx.ui.setWidget("buddy", renderBuddyWidget(companion, muted ? null : reaction, achievements, getWidgetWidth()));
98
- };
99
- this.resizeUnsubscribe = subscribeToWidgetResize(resizeHandler);
100
- }
101
83
  }
102
84
 
103
85
  function getScheduler(
package/adapters/pi/ui.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import type { Achievement } from "../../core/achievements.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
- import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
4
+ import { renderAchievementsSummary } from "./renderers.ts";
5
5
  import { PiBuddyStorage } from "./storage.ts";
6
- import { getWidgetWidth, subscribeToWidgetResize } from "../shared/widget-layout.ts";
6
+ import { BuddyWidget } from "../shared/widget-layout.ts";
7
7
 
8
8
  type PiBuddyUiContext = ExtensionContext & {
9
9
  setTimeout?: (callback: () => void, ms?: number) => Timer;
@@ -12,13 +12,7 @@ type PiBuddyUiContext = ExtensionContext & {
12
12
 
13
13
  export class PiBuddyUI {
14
14
  private readonly timers: Array<{ timer: Timer; clear: (timer: Timer) => void }> = [];
15
- private resizeUnsubscribe: (() => void) | null = null;
16
- private rendered: {
17
- ctx: PiBuddyUiContext;
18
- companion: Companion;
19
- reaction: ReactionState | null;
20
- achievements: Achievement[];
21
- } | null = null;
15
+ private readonly buddy = new BuddyWidget();
22
16
 
23
17
  constructor(private readonly storage: PiBuddyStorage) {}
24
18
 
@@ -34,9 +28,7 @@ export class PiBuddyUI {
34
28
 
35
29
  dispose(): void {
36
30
  this.cancelTimers();
37
- this.resizeUnsubscribe?.();
38
- this.resizeUnsubscribe = null;
39
- this.rendered = null;
31
+ this.buddy.dispose();
40
32
  }
41
33
 
42
34
  refresh(
@@ -47,12 +39,15 @@ export class PiBuddyUI {
47
39
  ): void {
48
40
  const currentReaction = reaction ?? null;
49
41
  const muted = this.storage.isMuted();
50
- this.rendered = { ctx, companion, reaction: currentReaction, achievements: [...achievements] };
51
- this.subscribeToResize();
42
+ const scheduler = getScheduler(ctx);
43
+
52
44
  ctx.ui.setStatus("buddy", undefined);
53
- ctx.ui.setWidget(
54
- "buddy",
55
- renderBuddyWidget(companion, muted ? null : currentReaction, achievements, getWidgetWidth()),
45
+ this.buddy.refresh(
46
+ (lines) => ctx.ui.setWidget("buddy", lines),
47
+ companion,
48
+ muted ? null : currentReaction,
49
+ [...achievements],
50
+ scheduler ?? undefined,
56
51
  );
57
52
 
58
53
  this.cancelTimers();
@@ -60,8 +55,6 @@ export class PiBuddyUI {
60
55
 
61
56
  const ttl = this.storage.loadConfig().reactionTTL;
62
57
  if (ttl <= 0) return;
63
-
64
- const scheduler = getScheduler(ctx);
65
58
  if (!scheduler) return;
66
59
 
67
60
  const reactionTimestamp = currentReaction.timestamp;
@@ -88,21 +81,10 @@ export class PiBuddyUI {
88
81
  unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
89
82
  remaining: Achievement[],
90
83
  ): void {
91
- this.rendered = null;
84
+ this.buddy.clear();
92
85
  ctx.ui.setWidget("buddy", renderAchievementsSummary(unlocked, remaining));
93
86
  ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
94
87
  }
95
-
96
- private subscribeToResize(): void {
97
- if (this.resizeUnsubscribe) return;
98
- const resizeHandler = () => {
99
- if (!this.rendered) return;
100
- const { ctx, companion, reaction, achievements } = this.rendered;
101
- const muted = this.storage.isMuted();
102
- ctx.ui.setWidget("buddy", renderBuddyWidget(companion, muted ? null : reaction, achievements, getWidgetWidth()));
103
- };
104
- this.resizeUnsubscribe = subscribeToWidgetResize(resizeHandler);
105
- }
106
88
  }
107
89
 
108
90
  function getScheduler(
@@ -56,6 +56,7 @@ export const DEFAULT_FILE_BUDDY_CONFIG: FileBuddyConfig = {
56
56
  bubblePosition: "top",
57
57
  showRarity: true,
58
58
  statusLineEnabled: true,
59
+ statuslineDensity: "auto",
59
60
  turnCommentModel: undefined,
60
61
  turnCommentTimeoutMs: undefined,
61
62
  muted: false,
@@ -164,16 +164,20 @@ function padLine(line: string, width: number): string {
164
164
  }
165
165
 
166
166
  function frameReaction(reaction: string, achievements: Achievement[], innerWidth: number): string[] {
167
- const reactionLines = wrapReaction(reaction, innerWidth, 2);
168
- const achievementLine = achievements[0] ? `🏆 ${achievements[0].name}` : "";
169
- const content = [...reactionLines, ...(achievementLine ? [achievementLine] : [])]
170
- .slice(0, 3)
171
- .map((line) => `${DIM_ITALIC}${line}${RESET}`);
167
+ const maxContentLines = WIDGET_MAX_LINES - 2;
168
+ const trimmedReaction = reaction.trim();
169
+ const achievementLines = achievements.map((achievement) => `🏆 ${achievement.name}`);
170
+ const reactionMax = Math.max(0, maxContentLines - achievementLines.length);
171
+ const reactionLines = trimmedReaction ? wrapReaction(trimmedReaction, innerWidth, reactionMax) : [];
172
+ const content = reactionLines.concat(achievementLines).slice(0, maxContentLines);
173
+ const paddedContent = content.length === 1 ? ["", ...content] : content;
174
+
172
175
  const top = `.${"-".repeat(innerWidth + 2)}.`;
176
+ const bottom = `\`${"-".repeat(innerWidth + 2)}'`;
173
177
  return [
174
178
  top,
175
- ...content.map((line) => `| ${padLine(line, innerWidth)} |`),
176
- `\`${"-".repeat(innerWidth + 2)}'`,
179
+ ...paddedContent.map((line) => `| ${DIM_ITALIC}${padLine(line, innerWidth)}${RESET} |`),
180
+ bottom,
177
181
  ];
178
182
  }
179
183
 
@@ -205,16 +209,21 @@ export function renderCompanionWidget(
205
209
  const maxBubbleInner = safeWidth - spriteWidth - tailWidth - 4;
206
210
  const hasContent = Boolean(reaction?.reaction?.trim()) || achievements.length > 0;
207
211
  const canFrame = hasContent && maxBubbleInner >= minBubbleInner;
208
- const bubbleInner = canFrame ? Math.min(44, maxBubbleInner) : 0;
212
+ const bubbleInner = canFrame ? maxBubbleInner : 0;
209
213
  const bubble = canFrame ? frameReaction(reaction?.reaction ?? "", achievements, bubbleInner) : [];
210
214
  const bubbleWidth = bubbleInner + 4;
211
215
  const cardWidth = canFrame ? bubbleWidth + tailWidth + spriteWidth : spriteWidth;
212
- const height = Math.max(clippedArt.length + 1, bubble.length);
216
+
217
+ const achievementSprites = canFrame
218
+ ? []
219
+ : achievements.map((achievement) => `${DIM_ITALIC}${padLine(`🏆 ${achievement.name}`, spriteWidth)}${RESET}`);
220
+ const spriteBody = [...clippedArt, clippedLabel, ...achievementSprites];
221
+ const height = Math.max(spriteBody.length, bubble.length);
213
222
  const connectorRow = canFrame ? Math.min(bubble.length - 2, Math.max(1, Math.floor(bubble.length / 2))) : -1;
214
223
 
215
224
  return Array.from({ length: Math.min(WIDGET_MAX_LINES, height) }, (_, index) => {
216
- const artLine = index < clippedArt.length ? clippedArt[index]! : index === clippedArt.length ? clippedLabel : "";
217
- const sprite = padLine(artLine, spriteWidth);
225
+ const spriteLine = spriteBody[index] ?? "";
226
+ const sprite = padLine(spriteLine, spriteWidth);
218
227
  let body: string;
219
228
  if (canFrame) {
220
229
  const bubbleLine = bubble[index] ?? " ".repeat(bubbleWidth);
@@ -226,3 +235,108 @@ export function renderCompanionWidget(
226
235
  return `${" ".repeat(Math.max(0, safeWidth - cardWidth))}${body}`;
227
236
  });
228
237
  }
238
+
239
+ export interface BuddyWidgetScheduler {
240
+ setTimeout(callback: () => void, ms?: number): Timer;
241
+ clearTimer(timer: Timer): void;
242
+ }
243
+
244
+ /**
245
+ * Width-aware buddy widget manager.
246
+ *
247
+ * Renders once when state arrives, then re-renders with the current terminal
248
+ * width after coalescing rapid process.stdout resize events. Callers supply
249
+ * only their harness `setWidget` and the current state; this class owns the
250
+ * resize subscription, debounce, and re-render logic.
251
+ */
252
+ export class BuddyWidget {
253
+ private readonly getWidth: () => number;
254
+ private scheduler: BuddyWidgetScheduler;
255
+ private readonly coalesceMs: number;
256
+ private setWidget: ((lines: string[]) => void) | null = null;
257
+ private state: { companion: Companion; reaction: ReactionState | null; achievements: Achievement[] } | null = null;
258
+ private resizeUnsubscribe: (() => void) | null = null;
259
+ private timer: Timer | null = null;
260
+
261
+ constructor(options?: {
262
+ getWidth?: () => number;
263
+ scheduler?: BuddyWidgetScheduler;
264
+ coalesceMs?: number;
265
+ }) {
266
+ this.getWidth = options?.getWidth ?? getWidgetWidth;
267
+ this.scheduler = options?.scheduler ?? {
268
+ setTimeout: globalThis.setTimeout.bind(globalThis),
269
+ clearTimer: globalThis.clearTimeout.bind(globalThis),
270
+ };
271
+ this.coalesceMs = options?.coalesceMs ?? 50;
272
+ }
273
+
274
+ /**
275
+ * Render immediately with the current state and start (or renew) the shared
276
+ * resize subscription. The `setWidget` callback is the adapter's harness
277
+ * setter bound to the current UI context.
278
+ */
279
+ refresh(
280
+ setWidget: (lines: string[]) => void,
281
+ companion: Companion,
282
+ reaction: ReactionState | null,
283
+ achievements: Achievement[] = [],
284
+ scheduler?: BuddyWidgetScheduler,
285
+ ): void {
286
+ // Cancel any pending resize timer before swapping the scheduler or state
287
+ // so clearTimer is always paired with the scheduler that created the timer.
288
+ this.cancelTimer();
289
+ this.setWidget = setWidget;
290
+ if (scheduler) this.scheduler = scheduler;
291
+ this.state = { companion, reaction, achievements };
292
+ this.renderAndSet();
293
+ this.subscribe();
294
+ }
295
+
296
+ /** Stop reacting to resizes without unsubscribing from the shared listener. */
297
+ clear(): void {
298
+ this.cancelTimer();
299
+ this.state = null;
300
+ }
301
+
302
+ /** Tear down timers and the shared resize listener. */
303
+ dispose(): void {
304
+ this.cancelTimer();
305
+ this.resizeUnsubscribe?.();
306
+ this.resizeUnsubscribe = null;
307
+ this.state = null;
308
+ this.setWidget = null;
309
+ }
310
+
311
+ private subscribe(): void {
312
+ if (this.resizeUnsubscribe) return;
313
+ this.resizeUnsubscribe = subscribeToWidgetResize(() => this.onResize());
314
+ }
315
+
316
+ private onResize(): void {
317
+ // A cleared widget has no state/setter to render; don't schedule work.
318
+ if (!this.state || !this.setWidget) return;
319
+ this.cancelTimer();
320
+ this.timer = this.scheduler.setTimeout(() => {
321
+ this.timer = null;
322
+ try {
323
+ this.renderAndSet();
324
+ } catch (error) {
325
+ console.error("BuddyWidget render failed:", error);
326
+ }
327
+ }, this.coalesceMs);
328
+ }
329
+ private renderAndSet(): void {
330
+ if (!this.state || !this.setWidget) return;
331
+ const width = this.getWidth();
332
+ const lines = renderCompanionWidget(this.state.companion, this.state.reaction, this.state.achievements, width);
333
+ this.setWidget(lines);
334
+ }
335
+
336
+ private cancelTimer(): void {
337
+ if (this.timer !== null) {
338
+ this.scheduler.clearTimer(this.timer);
339
+ this.timer = null;
340
+ }
341
+ }
342
+ }
package/core/model.ts CHANGED
@@ -38,6 +38,7 @@ export interface BuddyConfig {
38
38
  bubblePosition: "top" | "left";
39
39
  showRarity: boolean;
40
40
  statusLineEnabled: boolean;
41
+ statuslineDensity?: "auto" | "full" | "compact" | "minimal";
41
42
  turnCommentModel?: BuddyTurnCommentModelConfig;
42
43
  /** Optional per-host bound for end-of-turn reaction LLM calls (ms). */
43
44
  turnCommentTimeoutMs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -17,7 +17,6 @@
17
17
  "coding-buddy": "cli/index.ts"
18
18
  },
19
19
  "scripts": {
20
- "postinstall": "node scripts/postinstall.cjs",
21
20
  "server": "bun run server/index.ts",
22
21
  "pick": "bun run cli/pick.ts",
23
22
  "hunt": "bun run cli/hunt.ts",