@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.2"
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.2",
15
+ "version": "0.7.3",
16
16
  "author": {
17
17
  "name": "ramarivera"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-buddy",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
5
5
  "author": {
6
6
  "name": "ramarivera"
@@ -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 function displayWidth(text: string): number {
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, OMP_WIDGET_MAX_LINES - lines.length);
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 >= OMP_WIDGET_MAX_LINES) break;
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 artWidth = Math.max(...art.map(displayWidth), 0);
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
- const rows = Math.max(art.length, details.length);
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[] {
@@ -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 frame = Math.floor(Date.now() / 700);
23
- const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
24
- const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
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 lines = [
28
- `buddy · ${companion.name}`,
29
- `${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
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
- lines.push("", `“${reaction.reaction}”`);
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
- if (achievements.length > 0) {
40
- lines.push("", ...achievements.map((achievement) => `🏆 ${achievement.name}`));
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 lines;
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, renderBuddyStatus, renderBuddyWidget } from "./renderers.ts";
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
- const status = renderBuddyStatus(companion, muted ? null : currentReaction);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {