@stemtrooper/learningcode 0.4.9 → 0.4.11

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.
Binary file
@@ -265,6 +265,7 @@ async function main() {
265
265
  forced.push("--extension", join(here, "..", "extensions", "spark-quota.ts"));
266
266
  forced.push("--extension", join(here, "..", "extensions", "footer.ts"));
267
267
  forced.push("--extension", join(here, "..", "extensions", "banner.ts"));
268
+ forced.push("--extension", join(here, "..", "extensions", "orange-cat.ts"));
268
269
  if (process.env.LEARNINGCODE_PI_FLAGS) {
269
270
  forced.push(...process.env.LEARNINGCODE_PI_FLAGS.split(/\s+/).filter(Boolean));
270
271
  }
@@ -152,9 +152,57 @@ export function createBanner(theme: ThemeLike): HeaderComponent {
152
152
  * right moment: extensions are loaded in print and JSON modes too, where there is
153
153
  * no header to replace, and starting work in the factory would run for those.
154
154
  */
155
+
156
+ /** Terminal tab title. Just enough of the event context to set it. */
157
+ const TITLE = "learningcode by the learning curve";
158
+ type TitleContext = {
159
+ hasUI: boolean;
160
+ mode: string;
161
+ ui: { setTitle(title: string): void };
162
+ };
163
+
164
+ /**
165
+ * Pi rewrites its own π title after extensions bind and again after its async
166
+ * startup package check on Windows, so one write is never enough. The title is
167
+ * set on every event where Pi rewrites its own (startup, renames, new turns),
168
+ * plus once deferred past the startup check. Each write is last-writer-wins in
169
+ * our favour on at least one of those paths.
170
+ */
171
+ const TITLE_REASSERT_MS = 2500;
172
+
155
173
  export default function learningcodeBanner(pi: ExtensionAPI) {
156
- pi.on("session_start", async (_event, ctx) => {
157
- if (!ctx.hasUI || ctx.mode !== "tui") return;
158
- ctx.ui.setHeader((_tui, theme) => createBanner(theme as ThemeLike));
159
- });
174
+ let titleTimer: ReturnType<typeof setTimeout> | undefined;
175
+
176
+ const applyTitle = (ctx: TitleContext) => {
177
+ try {
178
+ ctx.ui.setTitle(TITLE);
179
+ } catch {
180
+ // Cosmetic only: a title failure must never break startup.
181
+ }
182
+ };
183
+
184
+ const titleHook =
185
+ (defer: boolean) => async (_event: unknown, ctx: TitleContext) => {
186
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
187
+ applyTitle(ctx);
188
+ if (!defer) return;
189
+ if (titleTimer) clearTimeout(titleTimer);
190
+ titleTimer = setTimeout(() => {
191
+ titleTimer = undefined;
192
+ applyTitle(ctx);
193
+ }, TITLE_REASSERT_MS);
194
+ titleTimer.unref?.();
195
+ };
196
+
197
+ pi.on("session_start", async (_event, ctx) => {
198
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
199
+ ctx.ui.setHeader((_tui, theme) => createBanner(theme as ThemeLike));
200
+ await titleHook(true)(_event, ctx);
201
+ });
202
+ pi.on("session_info_changed", titleHook(false));
203
+ pi.on("agent_start", titleHook(false));
204
+ pi.on("session_shutdown", async () => {
205
+ if (titleTimer) clearTimeout(titleTimer);
206
+ titleTimer = undefined;
207
+ });
160
208
  }
@@ -0,0 +1,131 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Image, allocateImageId, getCapabilities } from "@earendil-works/pi-tui";
3
+ import type { Component, TUI, Theme } from "@earendil-works/pi-tui";
4
+
5
+ /**
6
+ * Micro orange cat (opencode-style footprint: 5 cells, 1 row).
7
+ * Sitting front-facing cat: orange ears, blinking eyes, flicking tail.
8
+ * True-pixel PNG (30x12px) where image protocols exist, half-blocks elsewhere.
9
+ * The loader-row spinner is also swapped to a small orange rotating square.
10
+ */
11
+
12
+ const FRAMES = [
13
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAARElEQVR4nGNkwAH+L2X4j02cMZqBkRrqmRgGCDCNWjzsg5oRl4SIiAjWVPo6rwmretFJdVjF37x5M5qqwWA0OzHQCwAACZMNF3vfv8oAAAAASUVORK5CYII=",
14
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAARElEQVR4nGNkwAH+L2X4j02cMZqBkRrqmRgGCDCNWjzsg5oRl4SIiAjWVPo6rwmretFJdVjF37x5M5qqwWA0OzHQCwAACZMNF3vfv8oAAAAASUVORK5CYII=",
15
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAAQ0lEQVR4nGNkwAH+L2X4j02cMZqBkRrqmRgGCDCNWjzsg5oFl8TJk9jF/zdNw5p6T57MIsliJoYBAkyjFtMLDFhQAwCZmQy+VnpibQAAAABJRU5ErkJggg==",
16
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAARElEQVR4nGNkwAH+L2X4j02cMZqBkRrqmRgGCDCNWjzsg5oRl4SIiAjWVPo6rwmretFJdVjF37x5M5qqwWA0OzHQCwAACZMNF3vfv8oAAAAASUVORK5CYII=",
17
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAAT0lEQVR4nGM8kcfwnwELsJjEwIhN/P9S7OoZo0lTz8QwQIBp1OJhH9SMuCRERESwpsbXeU1Y1YtOqsMq/ubNG6x2MDEMEGAatZheYMCCGgCBNA+6BCtJTQAAAABJRU5ErkJggg==",
18
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAARElEQVR4nGNkwAH+L2X4j02cMZqBkRrqmRgGCDCNWjzsg5oRl4SIiAjWVPo6rwmretFJdVjF37x5M5qqwWA0OzHQCwAACZMNF3vfv8oAAAAASUVORK5CYII=",
19
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAATUlEQVR4nGNkwAH+L2X4j02cMZqBkRT1J09iN5+JYYAA06jFwz6oGXFJiIiIYE2lr/OasKoXnVSHVfzNmzdY7WBiGCDANGoxvcCABTUAbQkOTKbjrEAAAAAASUVORK5CYII=",
20
+ "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAMCAYAAAB1Lg0yAAAARElEQVR4nGNkwAH+L2X4j02cMZqBkRrqmRgGCDCNWjzsg5oRl4SIiAjWVPo6rwmretFJdVjF37x5M5qqwWA0OzHQCwAACZMNF3vfv8oAAAAASUVORK5CYII=",
21
+ ];
22
+
23
+ const SPIN = ["◰", "◳", "◲", "◱"].map((g) => "\x1b[38;2;255;165;0m" + g + "\x1b[39m");
24
+
25
+ // Braille fallback: 12x4 cells = 24x16 dots (4x half-block density).
26
+ // Orange silhouette of assets/cat.webp; eyes carved (blink), tail shifts.
27
+ const BRAILLE_SEQ: string[][] = [
28
+ ["⠓⣄⣠⠚"],
29
+ ["⠓⣄⣠⠚"],
30
+ ["⠓⣤⣤⠚"],
31
+ ["⠓⣄⣠⠚"],
32
+ ["⠒⣄⣠⠒"],
33
+ ["⠓⣄⣠⠚"]
34
+ ];
35
+ const FB_TRAVEL = 4;
36
+
37
+ function mirrorBraille(line: string): string {
38
+ // strip ANSI, mirror dot bits left-right, re-wrap
39
+ const core = line.replace(/\[[0-9;]*m/g, "");
40
+ let out = "";
41
+ for (let i = core.length - 1; i >= 0; i--) {
42
+ const b = core.charCodeAt(i)! - 0x2800;
43
+ const m =
44
+ ((b & 0x01) << 3) | ((b & 0x08) >> 3) |
45
+ ((b & 0x02) << 3) | ((b & 0x10) >> 3) |
46
+ ((b & 0x04) << 3) | ((b & 0x20) >> 3) |
47
+ ((b & 0x40) << 1) | ((b & 0x80) >> 1);
48
+ out += String.fromCharCode(0x2800 + m);
49
+ }
50
+ return "" + out + "";
51
+ }
52
+
53
+ function fallbackFrames(): string[][] {
54
+ const frames: string[][] = [];
55
+ for (let i = 0; i <= FB_TRAVEL; i++) {
56
+ const s = BRAILLE_SEQ[i % BRAILLE_SEQ.length]!;
57
+ frames.push(s.map((l) => " ".repeat(i) + l));
58
+ }
59
+ for (let i = FB_TRAVEL; i >= 0; i--) {
60
+ const s = BRAILLE_SEQ[i % BRAILLE_SEQ.length]!;
61
+ frames.push(s.map((l) => " ".repeat(i) + mirrorBraille(l)));
62
+ }
63
+ return frames;
64
+ }
65
+
66
+ class CatWidget implements Component {
67
+ private idx = 0;
68
+ private imageId = allocateImageId();
69
+ private fallback = fallbackFrames();
70
+ private images: Image[] = [];
71
+ private tui!: TUI;
72
+ constructor(tui: TUI, theme: Theme) {
73
+ this.tui = tui;
74
+ const fallbackColor = (s: string) => s;
75
+ for (const f of FRAMES) {
76
+ this.images.push(new Image(f, "image/png", { fallbackColor }, { imageId: this.imageId }));
77
+ }
78
+ void theme;
79
+ }
80
+ advance() {
81
+ this.idx = (this.idx + 1) % FRAMES.length;
82
+ this.invalidate();
83
+ this.tui.requestRender?.();
84
+ }
85
+ invalidate() {
86
+ for (const im of this.images) im.invalidate();
87
+ }
88
+ render(width: number): string[] {
89
+ if (!getCapabilities().images) return this.fallback[this.idx % this.fallback.length]!;
90
+ return this.images[this.idx % this.images.length]!.render(width);
91
+ }
92
+ }
93
+
94
+ export default function (pi: ExtensionAPI) {
95
+ pi.registerCommand("cat", {
96
+ description: "Show which cat renderer is active (pixel PNG or block fallback).",
97
+ handler: async (_args, ctx) => {
98
+ const caps = getCapabilities();
99
+ ctx.ui.notify(
100
+ "cat renderer: " + (caps.images ? "true-pixel PNG (" + caps.images + ")" : "block fallback (no image protocol)"),
101
+ "info",
102
+ );
103
+ },
104
+ });
105
+ let widget: CatWidget | null = null;
106
+ let timer: ReturnType<typeof setInterval> | null = null;
107
+
108
+ pi.on("session_start", async (_e, ctx: ExtensionContext) => {
109
+ ctx.ui.setWorkingIndicator({ frames: SPIN, intervalMs: 100 });
110
+ });
111
+
112
+ pi.on("agent_start", async (_e, ctx: ExtensionContext) => {
113
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
114
+ if (timer) clearInterval(timer);
115
+ widget = null;
116
+ ctx.ui.setWidget("tiny-cat", (tui, theme) => {
117
+ widget = new CatWidget(tui, theme as unknown as Theme);
118
+ return widget;
119
+ });
120
+ timer = setInterval(() => widget?.advance(), 220);
121
+ });
122
+
123
+ const stop = async (_e: unknown, ctx: ExtensionContext) => {
124
+ if (timer) clearInterval(timer);
125
+ timer = null;
126
+ widget = null;
127
+ ctx.ui.setWidget("tiny-cat", undefined);
128
+ };
129
+ pi.on("agent_settled", stop as never);
130
+ pi.on("session_shutdown", stop as never);
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stemtrooper/learningcode",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "TLC Spark coding agent for students: Pi wired to the Spark OpenAI-compatible endpoint with per-student tokens, quota and seat-queue awareness.",
5
5
  "type": "module",
6
6
  "license": "MIT",