@ramarivera/coding-buddy 0.8.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.
- package/README.md +23 -4
- package/adapters/omp/context.ts +1 -1
- package/adapters/omp/events.ts +286 -81
- package/adapters/omp/index.ts +3 -1
- package/adapters/omp/renderers.ts +4 -54
- package/adapters/omp/storage.ts +6 -1
- package/adapters/omp/ui.ts +17 -6
- package/adapters/pi/events.ts +280 -83
- package/adapters/pi/index.ts +11 -2
- package/adapters/pi/renderers.ts +3 -47
- package/adapters/pi/storage.ts +6 -1
- package/adapters/pi/ui.ts +17 -6
- package/adapters/shared/file-storage.ts +22 -0
- package/adapters/shared/widget-layout.ts +248 -2
- package/cli/settings.ts +1 -1
- package/cli/tui.tsx +1 -1
- package/core/command-service.ts +13 -0
- package/core/engine.ts +11 -2
- package/core/identity.ts +15 -0
- package/core/model.ts +3 -0
- package/core/ports.ts +4 -0
- package/core/reactions.ts +29 -2
- package/core/render-model.ts +4 -3
- package/package.json +6 -3
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +441 -14
- package/scripts/ci/check-vhs-goldens.sh +97 -0
- package/scripts/ci/fonts.conf +37 -0
- package/scripts/ci/goldens/baseline-40.png +0 -0
- package/scripts/ci/goldens/baseline-80.png +0 -0
- package/scripts/ci/goldens/cjk-80.png +0 -0
- package/scripts/ci/goldens/emoji-80.png +0 -0
- package/scripts/ci/render-statusline-pty.py +107 -0
- package/scripts/ci/render-vhs-goldens.sh +226 -0
- package/scripts/ci/statusline.tape +28 -2
- package/scripts/ci/vhs-density-fixtures.sh +27 -0
- package/scripts/ci/vhs-run-statusline.sh +28 -0
- package/scripts/ci/write-density-status.ts +86 -0
- package/scripts/gen-emoji-widths.ts +41 -17
- package/scripts/paths.sh +3 -1
- package/server/art.ts +70 -17
- package/server/hooks/buddy-comment.ts +166 -17
- package/server/hooks/file-type-react.ts +1 -1
- package/server/hooks/mood-react.ts +1 -1
- package/server/hooks/name-react.ts +1 -1
- package/server/hooks/react.ts +1 -1
- package/server/hooks/reaction-data.ts +10 -3
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +147 -41
- package/skills/buddy/SKILL.md +1 -1
- package/statusline/buddy-status.sh +488 -76
- package/statusline/emoji-text.data +3 -0
- package/statusline/emoji-widths.data +2 -3
- package/statusline/substatus.sh +52 -10
- package/scripts/postinstall.cjs +0 -42
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { configuredSharedUserId } from "../../core/identity.ts";
|
|
14
15
|
import {
|
|
15
16
|
EMPTY_GLOBAL,
|
|
16
17
|
EMPTY_SLOT,
|
|
@@ -55,7 +56,9 @@ export const DEFAULT_FILE_BUDDY_CONFIG: FileBuddyConfig = {
|
|
|
55
56
|
bubblePosition: "top",
|
|
56
57
|
showRarity: true,
|
|
57
58
|
statusLineEnabled: true,
|
|
59
|
+
statuslineDensity: "auto",
|
|
58
60
|
turnCommentModel: undefined,
|
|
61
|
+
turnCommentTimeoutMs: undefined,
|
|
59
62
|
muted: false,
|
|
60
63
|
};
|
|
61
64
|
|
|
@@ -312,6 +315,22 @@ export class FileBuddyStorage
|
|
|
312
315
|
});
|
|
313
316
|
}
|
|
314
317
|
|
|
318
|
+
updateSlot(
|
|
319
|
+
slot: string,
|
|
320
|
+
transform: (companion: Companion) => Companion | null,
|
|
321
|
+
): Companion | null {
|
|
322
|
+
return this.withLock(() => {
|
|
323
|
+
const manifest = this.loadManifest();
|
|
324
|
+
const existing = manifest.companions[slot];
|
|
325
|
+
if (!existing) return null;
|
|
326
|
+
const updated = transform(existing);
|
|
327
|
+
if (!updated) return null;
|
|
328
|
+
manifest.companions[slot] = updated;
|
|
329
|
+
this.saveManifest(manifest);
|
|
330
|
+
return updated;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
315
334
|
loadSlot(slot: string): Companion | null {
|
|
316
335
|
return this.loadManifest().companions[slot] ?? null;
|
|
317
336
|
}
|
|
@@ -505,6 +524,9 @@ export class FileBuddyStorage
|
|
|
505
524
|
}
|
|
506
525
|
|
|
507
526
|
ensureStableIdentity(): string {
|
|
527
|
+
const sharedUserId = configuredSharedUserId();
|
|
528
|
+
if (sharedUserId) return sharedUserId;
|
|
529
|
+
|
|
508
530
|
return this.withLock(() => {
|
|
509
531
|
this.ensureDir();
|
|
510
532
|
try {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import { RARITY_STARS } from "../../core/engine.ts";
|
|
2
|
+
import type { Achievement } from "../../core/achievements.ts";
|
|
3
|
+
import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
|
|
4
|
+
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
5
|
+
import { getRarityColor } from "../../server/theme.ts";
|
|
6
|
+
|
|
1
7
|
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
2
8
|
const ART_GAP = " ";
|
|
3
9
|
const DEFAULT_WIDGET_WIDTH = 78;
|
|
10
|
+
const RESET = "\x1b[0m";
|
|
11
|
+
const DIM_ITALIC = "\x1b[2;3m";
|
|
12
|
+
const resizeSubscribers = new Set<() => void>();
|
|
13
|
+
let resizeDispatcher: (() => void) | null = null;
|
|
4
14
|
|
|
5
15
|
export const WIDGET_MAX_LINES = 10;
|
|
6
16
|
|
|
@@ -8,10 +18,33 @@ export function stripAnsi(text: string): string {
|
|
|
8
18
|
return text.replace(ANSI_PATTERN, "");
|
|
9
19
|
}
|
|
10
20
|
|
|
21
|
+
// East-Asian Wide/Fullwidth ranges. These MUST stay in sync with char_width()
|
|
22
|
+
// in statusline/buddy-status.sh \u2014 the two surfaces render the same companion
|
|
23
|
+
// card, and a disagreement here shears the bubble: borders sized from an
|
|
24
|
+
// undercounted width, text rows drawn at their true width.
|
|
25
|
+
//
|
|
26
|
+
// Box Drawing (U+2500-U+257F) and Block Elements sit inside the CJK span but
|
|
27
|
+
// are narrow, so they are carved out explicitly, matching the shell.
|
|
28
|
+
const BOX_DRAWING_START = 0x2500; // 9472
|
|
29
|
+
const BOX_DRAWING_END = 0x25bf; // 9631
|
|
30
|
+
const CJK_START = 0x3000; // 12288 \u2014 CJK punctuation through Hangul syllables
|
|
31
|
+
const CJK_END = 0x9fff; // 40959
|
|
32
|
+
const FULLWIDTH_START = 0xff01; // 65281 \u2014 Fullwidth ASCII variants
|
|
33
|
+
const FULLWIDTH_END = 0xff60; // 65376
|
|
34
|
+
|
|
35
|
+
function isEastAsianWide(codePoint: number): boolean {
|
|
36
|
+
if (codePoint >= BOX_DRAWING_START && codePoint <= BOX_DRAWING_END) return false;
|
|
37
|
+
if (codePoint >= CJK_START && codePoint <= CJK_END) return true;
|
|
38
|
+
if (codePoint >= FULLWIDTH_START && codePoint <= FULLWIDTH_END) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
11
42
|
function characterWidth(character: string, next?: string): number {
|
|
12
43
|
if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
|
|
13
44
|
if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
|
|
14
|
-
|
|
45
|
+
if (/\p{Emoji_Presentation}/u.test(character)) return 2;
|
|
46
|
+
const codePoint = character.codePointAt(0);
|
|
47
|
+
return codePoint !== undefined && isEastAsianWide(codePoint) ? 2 : 1;
|
|
15
48
|
}
|
|
16
49
|
|
|
17
50
|
export function displayWidth(text: string): number {
|
|
@@ -69,7 +102,26 @@ export function wrapReaction(reaction: string, width: number, maxLines: number):
|
|
|
69
102
|
|
|
70
103
|
export function getWidgetWidth(): number {
|
|
71
104
|
const columns = process.stdout.columns;
|
|
72
|
-
return columns && columns > 0 ? Math.max(
|
|
105
|
+
return columns && columns > 0 ? Math.max(1, columns - 2) : DEFAULT_WIDGET_WIDTH;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Share one host resize listener across all adapter UI instances. */
|
|
109
|
+
export function subscribeToWidgetResize(callback: () => void): () => void {
|
|
110
|
+
resizeSubscribers.add(callback);
|
|
111
|
+
if (!resizeDispatcher) {
|
|
112
|
+
resizeDispatcher = () => {
|
|
113
|
+
for (const subscriber of resizeSubscribers) subscriber();
|
|
114
|
+
};
|
|
115
|
+
process.stdout.on("resize", resizeDispatcher);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
resizeSubscribers.delete(callback);
|
|
120
|
+
if (resizeSubscribers.size === 0 && resizeDispatcher) {
|
|
121
|
+
process.stdout.removeListener("resize", resizeDispatcher);
|
|
122
|
+
resizeDispatcher = null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
73
125
|
}
|
|
74
126
|
|
|
75
127
|
export function getDetailsWidth(art: string[], width: number = getWidgetWidth()): number {
|
|
@@ -94,3 +146,197 @@ export function composeDetailsAndArt(
|
|
|
94
146
|
return `${boundedDetailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(boundedDetailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
|
|
95
147
|
});
|
|
96
148
|
}
|
|
149
|
+
|
|
150
|
+
function trimArt(line: string): string {
|
|
151
|
+
return line.replace(/\s+$/g, "");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function centerToWidth(text: string, width: number): string {
|
|
155
|
+
const bounded = truncateToWidth(text, width);
|
|
156
|
+
const remaining = Math.max(0, width - displayWidth(bounded));
|
|
157
|
+
const left = Math.floor(remaining / 2);
|
|
158
|
+
return `${" ".repeat(left)}${bounded}${" ".repeat(remaining - left)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function padLine(line: string, width: number): string {
|
|
162
|
+
const bounded = displayWidth(line) > width ? truncateToWidth(line, width) : line;
|
|
163
|
+
return `${bounded}${" ".repeat(Math.max(0, width - displayWidth(bounded)))}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function frameReaction(reaction: string, achievements: Achievement[], innerWidth: number): string[] {
|
|
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
|
+
|
|
175
|
+
const top = `.${"-".repeat(innerWidth + 2)}.`;
|
|
176
|
+
const bottom = `\`${"-".repeat(innerWidth + 2)}'`;
|
|
177
|
+
return [
|
|
178
|
+
top,
|
|
179
|
+
...paddedContent.map((line) => `| ${DIM_ITALIC}${padLine(line, innerWidth)}${RESET} |`),
|
|
180
|
+
bottom,
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render the hero card shared by the OMP, Pi, and Claude Code surfaces.
|
|
186
|
+
* The bubble is deliberately dropped as a unit when the sprite cannot fit.
|
|
187
|
+
*/
|
|
188
|
+
export function renderCompanionWidget(
|
|
189
|
+
companion: Companion,
|
|
190
|
+
reaction: ReactionState | null | undefined,
|
|
191
|
+
achievements: Achievement[] = [],
|
|
192
|
+
width: number = getWidgetWidth(),
|
|
193
|
+
frame: number = Math.floor(Date.now() / 700),
|
|
194
|
+
): string[] {
|
|
195
|
+
const safeWidth = Math.max(1, Math.floor(width));
|
|
196
|
+
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
|
|
197
|
+
if (companion.bones.hat !== "none" && !art[0]?.trim()) art[0] = HAT_ART[companion.bones.hat];
|
|
198
|
+
|
|
199
|
+
const color = getRarityColor(companion.bones.rarity);
|
|
200
|
+
const label = `${companion.name} ${RARITY_STARS[companion.bones.rarity]}${companion.bones.shiny ? " ✨" : ""}`;
|
|
201
|
+
const artWidth = Math.max(...art.map(displayWidth), 0);
|
|
202
|
+
const labelWidth = displayWidth(label);
|
|
203
|
+
const spriteWidth = Math.min(safeWidth, Math.max(artWidth, labelWidth));
|
|
204
|
+
const clippedArt = art.map((line) => `${color}${padLine(line, spriteWidth)}${RESET}`);
|
|
205
|
+
const clippedLabel = `${color}${centerToWidth(label, spriteWidth)}${RESET}`;
|
|
206
|
+
|
|
207
|
+
const minBubbleInner = 12;
|
|
208
|
+
const tailWidth = 3;
|
|
209
|
+
const maxBubbleInner = safeWidth - spriteWidth - tailWidth - 4;
|
|
210
|
+
const hasContent = Boolean(reaction?.reaction?.trim()) || achievements.length > 0;
|
|
211
|
+
const canFrame = hasContent && maxBubbleInner >= minBubbleInner;
|
|
212
|
+
const bubbleInner = canFrame ? maxBubbleInner : 0;
|
|
213
|
+
const bubble = canFrame ? frameReaction(reaction?.reaction ?? "", achievements, bubbleInner) : [];
|
|
214
|
+
const bubbleWidth = bubbleInner + 4;
|
|
215
|
+
const cardWidth = canFrame ? bubbleWidth + tailWidth + spriteWidth : spriteWidth;
|
|
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);
|
|
222
|
+
const connectorRow = canFrame ? Math.min(bubble.length - 2, Math.max(1, Math.floor(bubble.length / 2))) : -1;
|
|
223
|
+
|
|
224
|
+
return Array.from({ length: Math.min(WIDGET_MAX_LINES, height) }, (_, index) => {
|
|
225
|
+
const spriteLine = spriteBody[index] ?? "";
|
|
226
|
+
const sprite = padLine(spriteLine, spriteWidth);
|
|
227
|
+
let body: string;
|
|
228
|
+
if (canFrame) {
|
|
229
|
+
const bubbleLine = bubble[index] ?? " ".repeat(bubbleWidth);
|
|
230
|
+
const tail = index === connectorRow ? "-- " : " ";
|
|
231
|
+
body = `${bubbleLine}${tail}${sprite}`;
|
|
232
|
+
} else {
|
|
233
|
+
body = sprite;
|
|
234
|
+
}
|
|
235
|
+
return `${" ".repeat(Math.max(0, safeWidth - cardWidth))}${body}`;
|
|
236
|
+
});
|
|
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/cli/settings.ts
CHANGED
|
@@ -19,7 +19,7 @@ if (!key) {
|
|
|
19
19
|
coding-buddy settings
|
|
20
20
|
─────────────────────
|
|
21
21
|
Comment cooldown: ${cfg.commentCooldown}s (0 = no throttling, default 30)
|
|
22
|
-
Reaction TTL: ${cfg.reactionTTL}s (0 = permanent, default
|
|
22
|
+
Reaction TTL: ${cfg.reactionTTL}s (0 = permanent, default 900)
|
|
23
23
|
|
|
24
24
|
Change: bun run settings cooldown <seconds>
|
|
25
25
|
bun run settings ttl <seconds>
|
package/cli/tui.tsx
CHANGED
|
@@ -1459,7 +1459,7 @@ interface SettingDef {
|
|
|
1459
1459
|
|
|
1460
1460
|
const SETTING_DEFS: SettingDef[] = [
|
|
1461
1461
|
{ key: "commentCooldown", label: "Comment Cooldown", description: ["Minimum seconds between", "buddy status line comments.", "", "Lower = chatty, Higher = quiet"], type: "number", min: 0, default: "30" },
|
|
1462
|
-
{ key: "reactionTTL", label: "Reaction TTL", description: ["How long reactions stay", "visible in status line.", "", "0 = permanent"], type: "number", min: 0, default: "
|
|
1462
|
+
{ key: "reactionTTL", label: "Reaction TTL", description: ["How long reactions stay", "visible in status line.", "", "0 = permanent"], type: "number", min: 0, default: "900" },
|
|
1463
1463
|
{ key: "bubbleStyle", label: "Bubble Style", description: ["Speech bubble style.", "", 'classic → "quoted"', "round → (parens)"], type: "options", options: ["classic", "round"], default: "classic" },
|
|
1464
1464
|
{ key: "bubblePosition", label: "Bubble Position", description: ["Bubble placement.", "", "top → above buddy", "left → beside buddy"], type: "options", options: ["top", "left"], default: "top" },
|
|
1465
1465
|
{ key: "showRarity", label: "Show Rarity", description: ["Show rarity stars in", "the status line.", "", "true → ★★★★ visible", "false → hidden"], type: "options", options: ["true", "false"], default: "true" },
|
package/core/command-service.ts
CHANGED
|
@@ -142,6 +142,19 @@ export class BuddyCommandService {
|
|
|
142
142
|
}));
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
setPersonalityForSlot(
|
|
146
|
+
slot: string,
|
|
147
|
+
personality: string,
|
|
148
|
+
expectedPersonality: string,
|
|
149
|
+
): Companion | null {
|
|
150
|
+
const trimmed = personality.trim();
|
|
151
|
+
if (!trimmed) throw new Error("Buddy personality cannot be empty.");
|
|
152
|
+
return this.deps.buddies.updateSlot(slot, (companion) => {
|
|
153
|
+
if (companion.personality !== expectedPersonality) return null;
|
|
154
|
+
return { ...companion, personality: trimmed };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
saveBuddy(slot?: string): SaveBuddyResult {
|
|
146
159
|
const { companion, slot: activeSlot } = this.ensureCompanion();
|
|
147
160
|
if (slot?.trim()) {
|
package/core/engine.ts
CHANGED
|
@@ -81,6 +81,13 @@ export const EYES = [
|
|
|
81
81
|
"\u00b0",
|
|
82
82
|
] as const;
|
|
83
83
|
export type Eye = (typeof EYES)[number];
|
|
84
|
+
const DEFAULT_EYE: Eye = "\u00b0";
|
|
85
|
+
|
|
86
|
+
/** Keep persisted/runtime eye values from leaking descriptor words into art. */
|
|
87
|
+
export function resolveEyeGlyph(eye: unknown): Eye {
|
|
88
|
+
return EYES.find((candidate) => candidate === eye) ?? DEFAULT_EYE;
|
|
89
|
+
}
|
|
90
|
+
|
|
84
91
|
|
|
85
92
|
export const HATS = [
|
|
86
93
|
"none",
|
|
@@ -131,6 +138,8 @@ export interface Companion {
|
|
|
131
138
|
hatchedAt: number;
|
|
132
139
|
userId: string;
|
|
133
140
|
}
|
|
141
|
+
export const PERSONALITY_MAX_LENGTH = 500;
|
|
142
|
+
|
|
134
143
|
|
|
135
144
|
// ─── Hash: wyhash via Bun.hash, pure JS fallback ───────────────────────────
|
|
136
145
|
// Matches Zig stdlib wyhash v4.2 (used by Bun.hash). The pure JS implementation
|
|
@@ -401,8 +410,8 @@ const FACE_TEMPLATES: Record<Species, string> = {
|
|
|
401
410
|
pikachu: "({E}ω{E})",
|
|
402
411
|
};
|
|
403
412
|
|
|
404
|
-
export function renderFace(species: Species, eye:
|
|
405
|
-
return FACE_TEMPLATES[species].replace(/\{E\}/g, eye);
|
|
413
|
+
export function renderFace(species: Species, eye: string): string {
|
|
414
|
+
return FACE_TEMPLATES[species].replace(/\{E\}/g, resolveEyeGlyph(eye));
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
export function renderBuddy(bones: BuddyBones): string {
|
package/core/identity.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Explicit opt-in identity shared by the Claude, OMP, and Pi adapters. */
|
|
5
|
+
export function configuredSharedUserId(): string | undefined {
|
|
6
|
+
const value = process.env.CODING_BUDDY_USER_ID?.trim();
|
|
7
|
+
return value || undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Shared state is opt-in so existing harness-specific worlds stay isolated. */
|
|
11
|
+
export function sharedStateDir(): string | undefined {
|
|
12
|
+
if (!configuredSharedUserId()) return undefined;
|
|
13
|
+
const configured = process.env.CODING_BUDDY_STATE_DIR?.trim();
|
|
14
|
+
return configured || join(homedir(), ".coding-buddy", "shared");
|
|
15
|
+
}
|
package/core/model.ts
CHANGED
|
@@ -38,7 +38,10 @@ 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;
|
|
43
|
+
/** Optional per-host bound for end-of-turn reaction LLM calls (ms). */
|
|
44
|
+
turnCommentTimeoutMs?: number;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
export interface GlobalCounters {
|
package/core/ports.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface BuddyRepository {
|
|
|
17
17
|
updateActive(transform: (companion: Companion) => Companion): Companion;
|
|
18
18
|
loadSlot(slot: string): Companion | null;
|
|
19
19
|
saveSlot(slot: string, companion: Companion): void;
|
|
20
|
+
updateSlot(
|
|
21
|
+
slot: string,
|
|
22
|
+
transform: (companion: Companion) => Companion | null,
|
|
23
|
+
): Companion | null;
|
|
20
24
|
deleteSlot(slot: string): void;
|
|
21
25
|
listSlots(): Array<{ slot: string; companion: Companion }>;
|
|
22
26
|
loadActiveSlot(): string | null;
|
package/core/reactions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { PERSONALITY_MAX_LENGTH, type BuddyStats, type Species, type Rarity } from "./engine.ts";
|
|
2
2
|
|
|
3
3
|
export type ReactionReason =
|
|
4
4
|
| "hatch" | "pet" | "error" | "test-fail" | "large-diff" | "turn" | "idle"
|
|
@@ -675,6 +675,33 @@ export function generatePersonalityPrompt(
|
|
|
675
675
|
`Inspiration words: ${vibes.join(", ")}`,
|
|
676
676
|
shiny ? "SHINY variant — extra special." : "",
|
|
677
677
|
"",
|
|
678
|
-
"Return JSON: {\"name\": \"1-14 chars\", \"personality\": \"2-3 sentences
|
|
678
|
+
"Return JSON: {\"name\": \"1-14 chars\", \"personality\": \"2-3 sentences, 1-500 chars\"}",
|
|
679
679
|
].filter(Boolean).join("\n");
|
|
680
680
|
}
|
|
681
|
+
export function extractGeneratedPersonality(
|
|
682
|
+
text: string,
|
|
683
|
+
maxLength: number = PERSONALITY_MAX_LENGTH,
|
|
684
|
+
): string | null {
|
|
685
|
+
const trimmed = text.trim();
|
|
686
|
+
if (!trimmed) return null;
|
|
687
|
+
|
|
688
|
+
let parsed: unknown;
|
|
689
|
+
try {
|
|
690
|
+
parsed = JSON.parse(trimmed);
|
|
691
|
+
} catch {
|
|
692
|
+
const match = trimmed.match(/\{[\s\S]*\}/);
|
|
693
|
+
if (!match) return null;
|
|
694
|
+
try {
|
|
695
|
+
parsed = JSON.parse(match[0]);
|
|
696
|
+
} catch {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
702
|
+
const personality = (parsed as Record<string, unknown>).personality;
|
|
703
|
+
if (typeof personality !== "string") return null;
|
|
704
|
+
const clean = personality.trim();
|
|
705
|
+
if (!clean || clean.length > maxLength) return null;
|
|
706
|
+
return clean;
|
|
707
|
+
}
|
package/core/render-model.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { resolveEyeGlyph, type Species } from "./engine.ts";
|
|
2
2
|
import { HAT_ART, SPECIES_ART } from "./art-data.ts";
|
|
3
3
|
|
|
4
|
-
export function getArtFrame(species: Species, eye:
|
|
4
|
+
export function getArtFrame(species: Species, eye: string, frame: number = 0): string[] {
|
|
5
5
|
const frames = SPECIES_ART[species];
|
|
6
6
|
const f = frames[frame % frames.length];
|
|
7
|
-
|
|
7
|
+
const glyph = resolveEyeGlyph(eye);
|
|
8
|
+
return f.map((line) => line.replace(/\{E\}/g, glyph));
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export { HAT_ART, SPECIES_ART };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ramarivera/coding-buddy",
|
|
3
|
-
"version": "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",
|
|
@@ -36,7 +35,11 @@
|
|
|
36
35
|
"tui": "bun run cli/tui.tsx",
|
|
37
36
|
"test": "bun test",
|
|
38
37
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"gen:emoji-widths": "bun run scripts/gen-emoji-widths.ts"
|
|
38
|
+
"gen:emoji-widths": "bun run scripts/gen-emoji-widths.ts",
|
|
39
|
+
"ci:statusline": "bash scripts/ci/check-statusline.sh",
|
|
40
|
+
"ci:vhs:render": "bash scripts/ci/render-vhs-goldens.sh",
|
|
41
|
+
"ci:vhs:update": "bash scripts/ci/render-vhs-goldens.sh --update",
|
|
42
|
+
"ci:vhs:check": "bash scripts/ci/check-vhs-goldens.sh"
|
|
40
43
|
},
|
|
41
44
|
"files": [
|
|
42
45
|
"core/",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
# Platform-matched VHS golden renderer.
|
|
3
|
+
#
|
|
4
|
+
# Goldens MUST be produced on linux/amd64 with the same font + tool pins that
|
|
5
|
+
# GitHub Actions ubuntu-latest uses. Rendering on macOS (or arm64 Linux) makes
|
|
6
|
+
# SSIM comparisons fail on font rasterization alone.
|
|
7
|
+
#
|
|
8
|
+
# Build:
|
|
9
|
+
# docker build --platform linux/amd64 -f scripts/ci/Dockerfile.vhs \
|
|
10
|
+
# -t coding-buddy-vhs:local scripts/ci
|
|
11
|
+
#
|
|
12
|
+
# Run (prefer scripts/ci/render-vhs-goldens.sh, which wraps this image):
|
|
13
|
+
# docker run --rm --platform linux/amd64 \
|
|
14
|
+
# -v "$PWD":/work -w /work coding-buddy-vhs:local \
|
|
15
|
+
# bash scripts/ci/render-vhs-goldens.sh --native [--update]
|
|
16
|
+
|
|
17
|
+
FROM --platform=linux/amd64 ubuntu:24.04
|
|
18
|
+
|
|
19
|
+
ENV DEBIAN_FRONTEND=noninteractive \
|
|
20
|
+
LANG=C.UTF-8 \
|
|
21
|
+
LC_ALL=C.UTF-8 \
|
|
22
|
+
COLORTERM=truecolor \
|
|
23
|
+
CI=
|
|
24
|
+
|
|
25
|
+
# Pin exact Ubuntu 24.04 (noble) package versions so runner image drift does
|
|
26
|
+
# not silently re-rasterize glyphs. Bump deliberately when re-baselining.
|
|
27
|
+
#
|
|
28
|
+
# Includes Chromium shared libs for vhs/rod headless browser, plus the fonts
|
|
29
|
+
# used by the tapes (DejaVu mono + Noto CJK/emoji).
|
|
30
|
+
RUN apt-get update -qq \
|
|
31
|
+
&& apt-get install -y -qq --no-install-recommends \
|
|
32
|
+
ca-certificates=20260601~24.04.1 \
|
|
33
|
+
curl=8.5.0-2ubuntu10.11 \
|
|
34
|
+
xz-utils=5.6.1+really5.4.5-1ubuntu0.3 \
|
|
35
|
+
unzip=6.0-28ubuntu4.1 \
|
|
36
|
+
jq=1.7.1-3ubuntu0.24.04.2 \
|
|
37
|
+
python3=3.12.3-0ubuntu2.1 \
|
|
38
|
+
ffmpeg=7:6.1.1-3ubuntu5 \
|
|
39
|
+
ttyd=1.7.4-1build2 \
|
|
40
|
+
fonts-dejavu-core=2.37-8 \
|
|
41
|
+
fonts-noto-core=20201225-2 \
|
|
42
|
+
fonts-noto-mono=20201225-2 \
|
|
43
|
+
fonts-noto-color-emoji=2.047-0ubuntu0.24.04.1 \
|
|
44
|
+
fonts-noto-cjk=1:20230817+repack1-3 \
|
|
45
|
+
fonts-liberation=1:2.1.5-3 \
|
|
46
|
+
fontconfig=2.15.0-1.1ubuntu2 \
|
|
47
|
+
findutils=4.9.0-5build1 \
|
|
48
|
+
bash=5.2.21-2ubuntu4 \
|
|
49
|
+
libnss3=2:3.98-1ubuntu0.2 \
|
|
50
|
+
libnspr4=2:4.35-1.1build1 \
|
|
51
|
+
libatk1.0-0t64=2.52.0-1build1 \
|
|
52
|
+
libatk-bridge2.0-0t64=2.52.0-1build1 \
|
|
53
|
+
libatspi2.0-0t64=2.52.0-1build1 \
|
|
54
|
+
libcups2t64=2.4.7-1.2ubuntu7.14 \
|
|
55
|
+
libdrm2=2.4.125-1ubuntu0.1~24.04.2 \
|
|
56
|
+
libxkbcommon0=1.6.0-1build1 \
|
|
57
|
+
libxcomposite1=1:0.4.5-1build3 \
|
|
58
|
+
libxdamage1=1:1.1.6-1build1 \
|
|
59
|
+
libxfixes3=1:6.0.0-2build1 \
|
|
60
|
+
libxrandr2=2:1.5.2-2build1 \
|
|
61
|
+
libgbm1=25.2.8-0ubuntu0.24.04.2 \
|
|
62
|
+
libasound2t64=1.2.11-1ubuntu0.3 \
|
|
63
|
+
libpango-1.0-0=1.52.1+ds-1build1 \
|
|
64
|
+
libcairo2=1.18.0-3build1 \
|
|
65
|
+
libxshmfence1=1.3-1build5 \
|
|
66
|
+
libglib2.0-0t64=2.80.0-6ubuntu3.8 \
|
|
67
|
+
libgtk-3-0t64=3.24.41-4ubuntu1.3 \
|
|
68
|
+
libx11-6=2:1.8.7-1build1 \
|
|
69
|
+
libx11-xcb1=2:1.8.7-1build1 \
|
|
70
|
+
libxcb1=1.15-1ubuntu2 \
|
|
71
|
+
libxext6=2:1.3.4-1build2 \
|
|
72
|
+
libxi6=2:1.8.1-1build1 \
|
|
73
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
74
|
+
|
|
75
|
+
# Pin vhs binary.
|
|
76
|
+
ARG VHS_VERSION=0.11.0
|
|
77
|
+
RUN bash -lc "set -euo pipefail \
|
|
78
|
+
&& TMP=\$(mktemp -d) \
|
|
79
|
+
&& BASE=https://github.com/charmbracelet/vhs/releases/download/v${VHS_VERSION} \
|
|
80
|
+
&& curl -fsSL -o \"\$TMP/vhs.tgz\" \"\${BASE}/vhs_${VHS_VERSION}_Linux_x86_64.tar.gz\" \
|
|
81
|
+
&& tar -xzf \"\$TMP/vhs.tgz\" -C \"\$TMP\" \
|
|
82
|
+
&& BIN=\$(find \"\$TMP\" -type f -name vhs | head -n1) \
|
|
83
|
+
&& install -m 755 \"\$BIN\" /usr/local/bin/vhs \
|
|
84
|
+
&& rm -rf \"\$TMP\" \
|
|
85
|
+
&& vhs --version"
|
|
86
|
+
|
|
87
|
+
# Bun (baseline build — OrbStack/QEMU amd64 often lacks AVX).
|
|
88
|
+
# Install system-wide so non-root `docker run --user` still finds it.
|
|
89
|
+
ARG BUN_VERSION=1.3.9
|
|
90
|
+
RUN bash -lc "set -euo pipefail \
|
|
91
|
+
&& TMP=\$(mktemp -d) \
|
|
92
|
+
&& curl -fsSL -o \"\$TMP/bun.zip\" \
|
|
93
|
+
\"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip\" \
|
|
94
|
+
&& unzip -q \"\$TMP/bun.zip\" -d \"\$TMP\" \
|
|
95
|
+
&& install -m 755 \"\$TMP/bun-linux-x64-baseline/bun\" /usr/local/bin/bun \
|
|
96
|
+
&& rm -rf \"\$TMP\" \
|
|
97
|
+
&& bun --version"
|
|
98
|
+
|
|
99
|
+
COPY fonts.conf /etc/fonts/conf.d/99-coding-buddy-vhs.conf
|
|
100
|
+
RUN fc-cache -f \
|
|
101
|
+
&& fc-match "DejaVu Sans Mono" \
|
|
102
|
+
&& fc-match "Noto Color Emoji" \
|
|
103
|
+
&& fc-match "Noto Sans CJK SC"
|
|
104
|
+
|
|
105
|
+
# Chromium refuses to start as root without --no-sandbox. Prefer the stock
|
|
106
|
+
# ubuntu user (uid 1000 on noble) when present; otherwise create vhs:1000.
|
|
107
|
+
RUN if id -u ubuntu >/dev/null 2>&1; then \
|
|
108
|
+
usermod -l vhs ubuntu; \
|
|
109
|
+
groupmod -n vhs ubuntu; \
|
|
110
|
+
mv /home/ubuntu /home/vhs; \
|
|
111
|
+
usermod -d /home/vhs -s /bin/bash vhs; \
|
|
112
|
+
else \
|
|
113
|
+
useradd --create-home --uid 1000 --shell /bin/bash vhs; \
|
|
114
|
+
fi \
|
|
115
|
+
&& mkdir -p /home/vhs/.cache /home/vhs/.config \
|
|
116
|
+
&& chown -R 1000:1000 /home/vhs
|
|
117
|
+
|
|
118
|
+
ENV HOME=/home/vhs \
|
|
119
|
+
XDG_CACHE_HOME=/home/vhs/.cache \
|
|
120
|
+
XDG_CONFIG_HOME=/home/vhs/.config
|
|
121
|
+
|
|
122
|
+
WORKDIR /work
|
|
123
|
+
USER vhs
|
|
124
|
+
ENTRYPOINT []
|
|
125
|
+
CMD ["bash", "scripts/ci/render-vhs-goldens.sh", "--native"]
|