@ramarivera/coding-buddy 0.7.2 → 0.7.3
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.
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
|
|
8
|
-
"version": "0.7.
|
|
8
|
+
"version": "0.7.3"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "coding-buddy",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
|
|
15
|
-
"version": "0.7.
|
|
15
|
+
"version": "0.7.3",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "ramarivera"
|
|
18
18
|
},
|
|
@@ -3,87 +3,25 @@ import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
|
|
|
3
3
|
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
4
|
import type { Achievement } from "../../core/achievements.ts";
|
|
5
5
|
import { getRarityColor } from "../../server/theme.ts";
|
|
6
|
+
import {
|
|
7
|
+
composeDetailsAndArt,
|
|
8
|
+
displayWidth,
|
|
9
|
+
getDetailsWidth,
|
|
10
|
+
getWidgetWidth,
|
|
11
|
+
stripAnsi,
|
|
12
|
+
WIDGET_MAX_LINES,
|
|
13
|
+
wrapReaction,
|
|
14
|
+
} from "../shared/widget-layout.ts";
|
|
6
15
|
|
|
7
|
-
export const OMP_WIDGET_MAX_LINES = 10;
|
|
8
|
-
|
|
9
|
-
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
10
16
|
const RESET = "\x1b[0m";
|
|
11
17
|
const DIM_ITALIC = "\x1b[2;3m";
|
|
12
|
-
const ART_GAP = " ";
|
|
13
|
-
const DEFAULT_WIDGET_WIDTH = 78;
|
|
14
|
-
|
|
15
|
-
function stripAnsi(text: string): string {
|
|
16
|
-
return text.replace(ANSI_PATTERN, "");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function characterWidth(character: string, next?: string): number {
|
|
20
|
-
if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
|
|
21
|
-
if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
|
|
22
|
-
return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
|
|
23
|
-
}
|
|
24
18
|
|
|
25
|
-
export
|
|
26
|
-
const characters = [...stripAnsi(text)];
|
|
27
|
-
let width = 0;
|
|
28
|
-
for (let index = 0; index < characters.length; index += 1) {
|
|
29
|
-
width += characterWidth(characters[index]!, characters[index + 1]);
|
|
30
|
-
}
|
|
31
|
-
return width;
|
|
32
|
-
}
|
|
19
|
+
export { displayWidth, WIDGET_MAX_LINES as OMP_WIDGET_MAX_LINES };
|
|
33
20
|
|
|
34
21
|
function trimArt(line: string): string {
|
|
35
22
|
return line.replace(/\s+$/g, "");
|
|
36
23
|
}
|
|
37
24
|
|
|
38
|
-
function truncateToWidth(text: string, width: number): string {
|
|
39
|
-
if (width <= 0) return "";
|
|
40
|
-
let result = "";
|
|
41
|
-
let currentWidth = 0;
|
|
42
|
-
for (const character of [...stripAnsi(text)]) {
|
|
43
|
-
const nextWidth = characterWidth(character);
|
|
44
|
-
if (currentWidth + nextWidth > width) break;
|
|
45
|
-
result += character;
|
|
46
|
-
currentWidth += nextWidth;
|
|
47
|
-
}
|
|
48
|
-
return result;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
|
|
52
|
-
const lines: string[] = [];
|
|
53
|
-
let current = "";
|
|
54
|
-
|
|
55
|
-
for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
|
|
56
|
-
if (!word) continue;
|
|
57
|
-
if (displayWidth(word) > width) {
|
|
58
|
-
if (current) lines.push(current);
|
|
59
|
-
lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
|
|
60
|
-
current = "";
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
65
|
-
if (displayWidth(candidate) > width) {
|
|
66
|
-
if (current) lines.push(current);
|
|
67
|
-
current = word;
|
|
68
|
-
} else {
|
|
69
|
-
current = candidate;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (current) lines.push(current);
|
|
74
|
-
if (lines.length <= maxLines) return lines;
|
|
75
|
-
|
|
76
|
-
const bounded = lines.slice(0, maxLines);
|
|
77
|
-
const last = bounded[maxLines - 1] ?? "";
|
|
78
|
-
bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
|
|
79
|
-
return bounded;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function getWidgetWidth(): number {
|
|
83
|
-
const columns = process.stdout.columns;
|
|
84
|
-
return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
25
|
function getFullArtFrame(companion: Companion, frame: number): string[] {
|
|
88
26
|
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
|
|
89
27
|
if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
|
|
@@ -107,12 +45,12 @@ function renderDetails(
|
|
|
107
45
|
];
|
|
108
46
|
|
|
109
47
|
if (reaction?.reaction) {
|
|
110
|
-
const remainingLines = Math.max(1,
|
|
48
|
+
const remainingLines = Math.max(1, WIDGET_MAX_LINES - lines.length);
|
|
111
49
|
lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
|
|
112
50
|
}
|
|
113
51
|
|
|
114
52
|
for (const achievement of achievements) {
|
|
115
|
-
if (lines.length >=
|
|
53
|
+
if (lines.length >= WIDGET_MAX_LINES) break;
|
|
116
54
|
lines.push(`🏆 ${achievement.name}`);
|
|
117
55
|
}
|
|
118
56
|
|
|
@@ -126,17 +64,9 @@ export function renderBuddyWidget(
|
|
|
126
64
|
width: number = getWidgetWidth(),
|
|
127
65
|
): string[] {
|
|
128
66
|
const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
|
|
129
|
-
const
|
|
130
|
-
const widgetWidth = Math.max(24, width);
|
|
131
|
-
const sideWidth = Math.max(8, widgetWidth - artWidth - ART_GAP.length);
|
|
67
|
+
const sideWidth = getDetailsWidth(art, width);
|
|
132
68
|
const details = renderDetails(companion, reaction, achievements, sideWidth);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return Array.from({ length: Math.min(OMP_WIDGET_MAX_LINES, rows) }, (_, index) => {
|
|
136
|
-
const artLine = art[index] ?? "";
|
|
137
|
-
const detailLine = details[index] ?? "";
|
|
138
|
-
return `${detailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(detailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
|
|
139
|
-
});
|
|
69
|
+
return composeDetailsAndArt(details, art, width, WIDGET_MAX_LINES);
|
|
140
70
|
}
|
|
141
71
|
|
|
142
72
|
export function renderBuddyStats(companion: Companion): string[] {
|
package/adapters/pi/renderers.ts
CHANGED
|
@@ -2,45 +2,59 @@ import { RARITY_STARS } from "../../core/engine.ts";
|
|
|
2
2
|
import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
|
|
3
3
|
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
4
|
import type { Achievement } from "../../core/achievements.ts";
|
|
5
|
+
import { getRarityColor } from "../../server/theme.ts";
|
|
6
|
+
import {
|
|
7
|
+
composeDetailsAndArt,
|
|
8
|
+
getDetailsWidth,
|
|
9
|
+
getWidgetWidth,
|
|
10
|
+
WIDGET_MAX_LINES,
|
|
11
|
+
wrapReaction,
|
|
12
|
+
} from "../shared/widget-layout.ts";
|
|
13
|
+
|
|
14
|
+
export const PI_WIDGET_MAX_LINES = WIDGET_MAX_LINES;
|
|
15
|
+
|
|
16
|
+
const RESET = "\x1b[0m";
|
|
17
|
+
const DIM_ITALIC = "\x1b[2;3m";
|
|
5
18
|
|
|
6
19
|
function trimArt(line: string): string {
|
|
7
20
|
return line.replace(/\s+$/g, "");
|
|
8
21
|
}
|
|
9
22
|
|
|
10
|
-
export function renderBuddyStatus(companion: Companion, reaction?: ReactionState | null): string {
|
|
11
|
-
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
12
|
-
const stars = RARITY_STARS[companion.bones.rarity];
|
|
13
|
-
const suffix = reaction?.reaction ? ` — ${reaction.reaction}` : "";
|
|
14
|
-
return `${trimArt(getArtFrame(companion.bones.species, companion.bones.eye, Date.now())[0])} ${companion.name} ${stars}${shiny}${suffix}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
23
|
export function renderBuddyWidget(
|
|
18
24
|
companion: Companion,
|
|
19
25
|
reaction?: ReactionState | null,
|
|
20
26
|
achievements: Achievement[] = [],
|
|
27
|
+
width: number = getWidgetWidth(),
|
|
21
28
|
): string[] {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
29
|
+
const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
|
|
30
|
+
const sideWidth = getDetailsWidth(art, width);
|
|
31
|
+
const color = getRarityColor(companion.bones.rarity);
|
|
25
32
|
const stars = RARITY_STARS[companion.bones.rarity];
|
|
26
33
|
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
`${companion.bones.rarity} ${companion.bones.species}
|
|
30
|
-
"",
|
|
31
|
-
...hat,
|
|
32
|
-
...art,
|
|
34
|
+
const details = [
|
|
35
|
+
`${color}${companion.name} ${stars}${shiny}${RESET}`,
|
|
36
|
+
`${companion.bones.rarity} ${companion.bones.species}`,
|
|
33
37
|
];
|
|
34
38
|
|
|
35
39
|
if (reaction?.reaction) {
|
|
36
|
-
|
|
40
|
+
const remainingLines = Math.max(1, PI_WIDGET_MAX_LINES - details.length);
|
|
41
|
+
details.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
for (const achievement of achievements) {
|
|
45
|
+
if (details.length >= PI_WIDGET_MAX_LINES) break;
|
|
46
|
+
details.push(`🏆 ${achievement.name}`);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
return
|
|
49
|
+
return composeDetailsAndArt(details, art, width, PI_WIDGET_MAX_LINES);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getFullArtFrame(companion: Companion, frame: number): string[] {
|
|
53
|
+
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
|
|
54
|
+
if (companion.bones.hat !== "none" && !(art[0] ?? "").trim()) {
|
|
55
|
+
art[0] = HAT_ART[companion.bones.hat];
|
|
56
|
+
}
|
|
57
|
+
return art.map(trimArt);
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
export function renderBuddyStats(companion: Companion): string[] {
|
package/adapters/pi/ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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,
|
|
4
|
+
import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
|
|
5
5
|
import { PiBuddyStorage } from "./storage.ts";
|
|
6
6
|
|
|
7
7
|
type PiBuddyUiContext = ExtensionContext & {
|
|
@@ -32,8 +32,7 @@ export class PiBuddyUI {
|
|
|
32
32
|
): void {
|
|
33
33
|
const currentReaction = reaction ?? null;
|
|
34
34
|
const muted = this.storage.isMuted();
|
|
35
|
-
|
|
36
|
-
ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
|
|
35
|
+
ctx.ui.setStatus("buddy", undefined);
|
|
37
36
|
ctx.ui.setWidget(
|
|
38
37
|
"buddy",
|
|
39
38
|
renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
2
|
+
const ART_GAP = " ";
|
|
3
|
+
const DEFAULT_WIDGET_WIDTH = 78;
|
|
4
|
+
|
|
5
|
+
export const WIDGET_MAX_LINES = 10;
|
|
6
|
+
|
|
7
|
+
export function stripAnsi(text: string): string {
|
|
8
|
+
return text.replace(ANSI_PATTERN, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function characterWidth(character: string, next?: string): number {
|
|
12
|
+
if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
|
|
13
|
+
if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
|
|
14
|
+
return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function displayWidth(text: string): number {
|
|
18
|
+
const characters = [...stripAnsi(text)];
|
|
19
|
+
let width = 0;
|
|
20
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
21
|
+
width += characterWidth(characters[index]!, characters[index + 1]);
|
|
22
|
+
}
|
|
23
|
+
return width;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function truncateToWidth(text: string, width: number): string {
|
|
27
|
+
if (width <= 0) return "";
|
|
28
|
+
let result = "";
|
|
29
|
+
let currentWidth = 0;
|
|
30
|
+
for (const character of [...stripAnsi(text)]) {
|
|
31
|
+
const nextWidth = characterWidth(character);
|
|
32
|
+
if (currentWidth + nextWidth > width) break;
|
|
33
|
+
result += character;
|
|
34
|
+
currentWidth += nextWidth;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
|
|
40
|
+
const lines: string[] = [];
|
|
41
|
+
let current = "";
|
|
42
|
+
|
|
43
|
+
for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
|
|
44
|
+
if (!word) continue;
|
|
45
|
+
if (displayWidth(word) > width) {
|
|
46
|
+
if (current) lines.push(current);
|
|
47
|
+
lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
|
|
48
|
+
current = "";
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
53
|
+
if (displayWidth(candidate) > width) {
|
|
54
|
+
if (current) lines.push(current);
|
|
55
|
+
current = word;
|
|
56
|
+
} else {
|
|
57
|
+
current = candidate;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (current) lines.push(current);
|
|
62
|
+
if (lines.length <= maxLines) return lines;
|
|
63
|
+
|
|
64
|
+
const bounded = lines.slice(0, maxLines);
|
|
65
|
+
const last = bounded[maxLines - 1] ?? "";
|
|
66
|
+
bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
|
|
67
|
+
return bounded;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getWidgetWidth(): number {
|
|
71
|
+
const columns = process.stdout.columns;
|
|
72
|
+
return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getDetailsWidth(art: string[], width: number = getWidgetWidth()): number {
|
|
76
|
+
const artWidth = Math.max(...art.map(displayWidth), 0);
|
|
77
|
+
return Math.max(8, Math.max(24, width) - artWidth - ART_GAP.length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function composeDetailsAndArt(
|
|
81
|
+
details: string[],
|
|
82
|
+
art: string[],
|
|
83
|
+
width: number = getWidgetWidth(),
|
|
84
|
+
maxLines: number = WIDGET_MAX_LINES,
|
|
85
|
+
): string[] {
|
|
86
|
+
const artWidth = Math.max(...art.map(displayWidth), 0);
|
|
87
|
+
const sideWidth = getDetailsWidth(art, width);
|
|
88
|
+
const rows = Math.max(art.length, details.length);
|
|
89
|
+
|
|
90
|
+
return Array.from({ length: Math.min(maxLines, rows) }, (_, index) => {
|
|
91
|
+
const artLine = art[index] ?? "";
|
|
92
|
+
const detailLine = details[index] ?? "";
|
|
93
|
+
const boundedDetailLine = displayWidth(detailLine) > sideWidth ? truncateToWidth(detailLine, sideWidth) : detailLine;
|
|
94
|
+
return `${boundedDetailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(boundedDetailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
|
|
95
|
+
});
|
|
96
|
+
}
|