im-pickle-rick 0.1.0

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.
Files changed (128) hide show
  1. package/README.md +242 -0
  2. package/bin.js +3 -0
  3. package/dist/pickle +0 -0
  4. package/dist/worker-executor.js +207 -0
  5. package/package.json +53 -0
  6. package/src/games/GameSidebarManager.test.ts +64 -0
  7. package/src/games/GameSidebarManager.ts +78 -0
  8. package/src/games/gameboy/GameboyView.test.ts +25 -0
  9. package/src/games/gameboy/GameboyView.ts +100 -0
  10. package/src/games/gameboy/gameboy-polyfills.ts +313 -0
  11. package/src/games/index.test.ts +9 -0
  12. package/src/games/index.ts +4 -0
  13. package/src/games/snake/SnakeGame.test.ts +35 -0
  14. package/src/games/snake/SnakeGame.ts +145 -0
  15. package/src/games/snake/SnakeView.test.ts +25 -0
  16. package/src/games/snake/SnakeView.ts +290 -0
  17. package/src/index.test.ts +24 -0
  18. package/src/index.ts +141 -0
  19. package/src/services/commands/worker.test.ts +14 -0
  20. package/src/services/commands/worker.ts +262 -0
  21. package/src/services/config/index.ts +2 -0
  22. package/src/services/config/settings.test.ts +42 -0
  23. package/src/services/config/settings.ts +220 -0
  24. package/src/services/config/state.test.ts +88 -0
  25. package/src/services/config/state.ts +130 -0
  26. package/src/services/config/types.ts +39 -0
  27. package/src/services/execution/index.ts +1 -0
  28. package/src/services/execution/pickle-source.test.ts +88 -0
  29. package/src/services/execution/pickle-source.ts +264 -0
  30. package/src/services/execution/prompt.test.ts +93 -0
  31. package/src/services/execution/prompt.ts +322 -0
  32. package/src/services/execution/sequential.test.ts +91 -0
  33. package/src/services/execution/sequential.ts +422 -0
  34. package/src/services/execution/worker-client.ts +94 -0
  35. package/src/services/execution/worker-executor.ts +41 -0
  36. package/src/services/execution/worker.test.ts +73 -0
  37. package/src/services/git/branch.test.ts +147 -0
  38. package/src/services/git/branch.ts +128 -0
  39. package/src/services/git/diff.test.ts +113 -0
  40. package/src/services/git/diff.ts +323 -0
  41. package/src/services/git/index.ts +4 -0
  42. package/src/services/git/pr.test.ts +104 -0
  43. package/src/services/git/pr.ts +192 -0
  44. package/src/services/git/worktree.test.ts +99 -0
  45. package/src/services/git/worktree.ts +141 -0
  46. package/src/services/providers/base.test.ts +86 -0
  47. package/src/services/providers/base.ts +438 -0
  48. package/src/services/providers/codex.test.ts +39 -0
  49. package/src/services/providers/codex.ts +208 -0
  50. package/src/services/providers/gemini.test.ts +40 -0
  51. package/src/services/providers/gemini.ts +169 -0
  52. package/src/services/providers/index.test.ts +28 -0
  53. package/src/services/providers/index.ts +41 -0
  54. package/src/services/providers/opencode.test.ts +64 -0
  55. package/src/services/providers/opencode.ts +228 -0
  56. package/src/services/providers/types.ts +44 -0
  57. package/src/skills/code-implementer.md +105 -0
  58. package/src/skills/code-researcher.md +78 -0
  59. package/src/skills/implementation-planner.md +105 -0
  60. package/src/skills/plan-reviewer.md +100 -0
  61. package/src/skills/prd-drafter.md +123 -0
  62. package/src/skills/research-reviewer.md +79 -0
  63. package/src/skills/ruthless-refactorer.md +52 -0
  64. package/src/skills/ticket-manager.md +135 -0
  65. package/src/types/index.ts +2 -0
  66. package/src/types/rpc.ts +14 -0
  67. package/src/types/tasks.ts +50 -0
  68. package/src/types.d.ts +9 -0
  69. package/src/ui/common.ts +28 -0
  70. package/src/ui/components/FilePickerView.test.ts +79 -0
  71. package/src/ui/components/FilePickerView.ts +161 -0
  72. package/src/ui/components/MultiLineInput.test.ts +27 -0
  73. package/src/ui/components/MultiLineInput.ts +233 -0
  74. package/src/ui/components/SessionChip.test.ts +69 -0
  75. package/src/ui/components/SessionChip.ts +481 -0
  76. package/src/ui/components/ToyboxSidebar.test.ts +36 -0
  77. package/src/ui/components/ToyboxSidebar.ts +329 -0
  78. package/src/ui/components/refactor_plan.md +35 -0
  79. package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
  80. package/src/ui/controllers/DashboardController.ts +650 -0
  81. package/src/ui/dashboard.test.ts +43 -0
  82. package/src/ui/dashboard.ts +309 -0
  83. package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
  84. package/src/ui/dialogs/DashboardDialog.ts +399 -0
  85. package/src/ui/dialogs/Dialog.test.ts +50 -0
  86. package/src/ui/dialogs/Dialog.ts +241 -0
  87. package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
  88. package/src/ui/dialogs/DialogSidebar.ts +71 -0
  89. package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
  90. package/src/ui/dialogs/DiffViewDialog.ts +510 -0
  91. package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
  92. package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
  93. package/src/ui/dialogs/test-utils.ts +232 -0
  94. package/src/ui/file-picker-utils.test.ts +71 -0
  95. package/src/ui/file-picker-utils.ts +200 -0
  96. package/src/ui/input-chrome.test.ts +62 -0
  97. package/src/ui/input-chrome.ts +172 -0
  98. package/src/ui/logger.test.ts +68 -0
  99. package/src/ui/logger.ts +45 -0
  100. package/src/ui/mock-factory.ts +6 -0
  101. package/src/ui/spinner.test.ts +65 -0
  102. package/src/ui/spinner.ts +41 -0
  103. package/src/ui/test-setup.ts +300 -0
  104. package/src/ui/theme.test.ts +23 -0
  105. package/src/ui/theme.ts +16 -0
  106. package/src/ui/views/LandingView.integration.test.ts +21 -0
  107. package/src/ui/views/LandingView.test.ts +24 -0
  108. package/src/ui/views/LandingView.ts +221 -0
  109. package/src/ui/views/LogView.test.ts +24 -0
  110. package/src/ui/views/LogView.ts +277 -0
  111. package/src/ui/views/ToyboxView.test.ts +46 -0
  112. package/src/ui/views/ToyboxView.ts +323 -0
  113. package/src/utils/clipboard.test.ts +86 -0
  114. package/src/utils/clipboard.ts +100 -0
  115. package/src/utils/index.test.ts +68 -0
  116. package/src/utils/index.ts +95 -0
  117. package/src/utils/persona.test.ts +12 -0
  118. package/src/utils/persona.ts +8 -0
  119. package/src/utils/project-root.test.ts +38 -0
  120. package/src/utils/project-root.ts +22 -0
  121. package/src/utils/resources.test.ts +64 -0
  122. package/src/utils/resources.ts +92 -0
  123. package/src/utils/search.test.ts +48 -0
  124. package/src/utils/search.ts +103 -0
  125. package/src/utils/session-tracker.test.ts +46 -0
  126. package/src/utils/session-tracker.ts +67 -0
  127. package/src/utils/spinner.test.ts +54 -0
  128. package/src/utils/spinner.ts +87 -0
@@ -0,0 +1,277 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import {
4
+ CliRenderer,
5
+ BoxRenderable,
6
+ TextRenderable,
7
+ StyledText,
8
+ parseColor,
9
+ type TextChunk,
10
+ ScrollBoxRenderable,
11
+ } from "@opentui/core";
12
+ import { THEME } from "../theme.js";
13
+
14
+ interface LogLine {
15
+ content: string;
16
+ color: string;
17
+ }
18
+
19
+ export class LogView {
20
+ public root: BoxRenderable;
21
+ private logFilePath: string;
22
+ private fileHandle: fsp.FileHandle | null = null;
23
+ private lastSize: number = 0;
24
+ private tailInterval: ReturnType<typeof setTimeout> | null = null;
25
+ private tailing: boolean = false;
26
+ private isDestroyed = false;
27
+ private renderer: CliRenderer;
28
+ private lines: LogLine[] = [];
29
+ private textRenderable: TextRenderable;
30
+ private scrollBox: ScrollBoxRenderable;
31
+ private readonly MAX_LINES = 8000; // Allow deeper backscroll before truncation
32
+ private readonly RENDER_CHUNK_SIZE = this.MAX_LINES; // Render everything we keep so older iterations stay visible
33
+ private updateTimeout: ReturnType<typeof setTimeout> | null = null;
34
+ private onNewLines?: () => void;
35
+ private lastUpdateTime: number = 0;
36
+
37
+ constructor(renderer: CliRenderer, logFilePath: string, onNewLines?: () => void) {
38
+ this.renderer = renderer;
39
+ this.logFilePath = logFilePath;
40
+ this.onNewLines = onNewLines;
41
+
42
+ this.root = new BoxRenderable(renderer, {
43
+ id: "log-view-root",
44
+ width: "100%",
45
+ height: "100%",
46
+ backgroundColor: THEME.bg,
47
+ flexDirection: "column",
48
+ flexGrow: 1,
49
+ });
50
+
51
+ this.scrollBox = new ScrollBoxRenderable(renderer, {
52
+ id: "log-view-scroll",
53
+ width: "100%",
54
+ height: "100%",
55
+ flexGrow: 1,
56
+ scrollY: true,
57
+ stickyScroll: true,
58
+ stickyStart: "bottom",
59
+ backgroundColor: THEME.bg,
60
+ scrollbarOptions: {
61
+ trackOptions: {
62
+ backgroundColor: THEME.darkAccent,
63
+ foregroundColor: THEME.accent,
64
+ },
65
+ },
66
+ });
67
+
68
+ this.textRenderable = new TextRenderable(renderer, {
69
+ id: "log-view-text",
70
+ width: "100%",
71
+ height: "auto",
72
+ content: "[ Initializing Log View... ]",
73
+ fg: THEME.dim,
74
+ });
75
+ this.scrollBox.add(this.textRenderable);
76
+ this.root.add(this.scrollBox);
77
+
78
+ this.init();
79
+ }
80
+
81
+ private formatLogLine(content: string): LogLine {
82
+ const stripped = this.stripAnsi(content);
83
+ const trimmed = stripped.trim();
84
+
85
+ let color = THEME.text;
86
+
87
+ if (trimmed.startsWith("[Phase") || trimmed.startsWith("[Iteration") || trimmed.startsWith("Phase")) {
88
+ color = THEME.accent;
89
+ } else if (trimmed.startsWith(">>")) {
90
+ color = THEME.blue;
91
+ } else if (/ERROR|FAILED|Exception|Error:/i.test(trimmed)) {
92
+ color = THEME.error;
93
+ } else if (/WARNING|CAUTION/i.test(trimmed)) {
94
+ color = THEME.warning;
95
+ } else if (/\[DONE\]|SUCCESS|Successfully/i.test(trimmed)) {
96
+ color = THEME.green;
97
+ }
98
+
99
+ return { content: stripped, color };
100
+ }
101
+
102
+ private async init() {
103
+ try {
104
+ await fsp.access(this.logFilePath, fs.constants.R_OK);
105
+ this.fileHandle = await fsp.open(this.logFilePath, "r");
106
+ const stats = await this.fileHandle.stat();
107
+ this.lastSize = stats.size;
108
+
109
+ if (stats.size > 0) {
110
+ const content = await fsp.readFile(this.logFilePath, "utf8");
111
+ const rawLines = content.split("\n");
112
+
113
+ const linesToProcess = rawLines[rawLines.length - 1] === ""
114
+ ? rawLines.slice(0, -1)
115
+ : rawLines;
116
+
117
+ const lastLines = linesToProcess.slice(-this.MAX_LINES);
118
+ this.addRawLines(lastLines);
119
+ }
120
+ } catch (error) {
121
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code !== "ENOENT") {
122
+ this.addRawLines([`[Error reading log: ${error.message}]`]);
123
+ }
124
+ }
125
+
126
+ this.startTailing();
127
+ }
128
+
129
+ public startTailing() {
130
+ if (this.tailing) return;
131
+ this.tailing = true;
132
+ this.poll();
133
+ }
134
+
135
+ private async poll() {
136
+ if (!this.tailing) return;
137
+ try {
138
+ await this.checkFile();
139
+ } catch (e) {
140
+ // Silence background poll errors
141
+ } finally {
142
+ this.tailInterval = setTimeout(() => this.poll(), 500) as any;
143
+ }
144
+ }
145
+
146
+ public stopTailing() {
147
+ this.tailing = false;
148
+ if (this.tailInterval) {
149
+ clearTimeout(this.tailInterval);
150
+ this.tailInterval = null;
151
+ }
152
+ }
153
+
154
+ private async checkFile() {
155
+ try {
156
+ if (!this.fileHandle) {
157
+ try {
158
+ await fsp.access(this.logFilePath, fs.constants.R_OK);
159
+ this.fileHandle = await fsp.open(this.logFilePath, "r");
160
+ const stats = await this.fileHandle.stat();
161
+ this.lastSize = stats.size;
162
+ return;
163
+ } catch {
164
+ return;
165
+ }
166
+ }
167
+
168
+ const stats = await this.fileHandle.stat();
169
+ if (stats.size > this.lastSize) {
170
+ const bufferSize = stats.size - this.lastSize;
171
+ const buffer = Buffer.alloc(bufferSize);
172
+
173
+ await this.fileHandle.read(buffer, 0, bufferSize, this.lastSize);
174
+
175
+ const content = buffer.toString();
176
+ this.lastSize = stats.size;
177
+
178
+ const rawLines = content.split("\n");
179
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
180
+ rawLines.pop();
181
+ }
182
+
183
+ this.addRawLines(rawLines);
184
+ } else if (stats.size < this.lastSize) {
185
+ this.lastSize = stats.size;
186
+ this.addRawLines(["[Log file truncated]"]);
187
+ }
188
+ } catch (error) {
189
+ if (this.fileHandle) {
190
+ await this.fileHandle.close().catch(() => {});
191
+ this.fileHandle = null;
192
+ }
193
+ this.addRawLines([`[Error reading log file: ${error}]`]);
194
+ }
195
+ }
196
+
197
+ private stripAnsi(text: string): string {
198
+ return text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
199
+ }
200
+
201
+ private addRawLines(newLines: string[]) {
202
+ if (this.isDestroyed) return;
203
+ if (newLines.length === 0) return;
204
+
205
+ const formatted = newLines.map(line => this.formatLogLine(line));
206
+
207
+ this.lines.push(...formatted);
208
+ if (this.lines.length > this.MAX_LINES) {
209
+ this.lines = this.lines.slice(-this.MAX_LINES);
210
+ }
211
+
212
+ // Clear existing timeout and force immediate update if it's been > 500ms
213
+ const now = Date.now();
214
+ const timeSinceLastUpdate = now - this.lastUpdateTime;
215
+
216
+ if (this.updateTimeout) {
217
+ clearTimeout(this.updateTimeout);
218
+ this.updateTimeout = null;
219
+ }
220
+
221
+ // Use shorter timeout for more frequent updates, or immediate if it's been a while
222
+ const timeoutDuration = timeSinceLastUpdate > 500 ? 0 : 50;
223
+
224
+ this.updateTimeout = setTimeout(() => {
225
+ this.updateRenderable();
226
+ this.onNewLines?.();
227
+ this.updateTimeout = null;
228
+ }, timeoutDuration);
229
+ }
230
+
231
+ private updateRenderable() {
232
+ if (this.isDestroyed) return;
233
+ const now = Date.now();
234
+ this.lastUpdateTime = now;
235
+
236
+ // For very long logs, only render the last RENDER_CHUNK_SIZE lines to improve performance
237
+ const linesToRender = this.lines.length > this.RENDER_CHUNK_SIZE
238
+ ? this.lines.slice(-this.RENDER_CHUNK_SIZE)
239
+ : this.lines;
240
+
241
+ const chunks: TextChunk[] = linesToRender.map((line, i) => ({
242
+ __isChunk: true,
243
+ text: line.content + (i === linesToRender.length - 1 ? "" : "\n"),
244
+ fg: parseColor(line.color),
245
+ }));
246
+
247
+ // Add a truncation indicator if we're not showing all lines
248
+ if (this.lines.length > this.RENDER_CHUNK_SIZE) {
249
+ const hiddenCount = this.lines.length - this.RENDER_CHUNK_SIZE;
250
+ chunks.unshift({
251
+ __isChunk: true,
252
+ text: `[... ${hiddenCount} earlier lines hidden for performance ...]\n`,
253
+ fg: parseColor(THEME.dim),
254
+ });
255
+ }
256
+
257
+ this.textRenderable.content = new StyledText(chunks);
258
+ if (this.scrollBox) {
259
+ this.scrollBox.scrollTo({ x: 0, y: this.scrollBox.scrollHeight });
260
+ }
261
+ }
262
+
263
+ public async destroy() {
264
+ if (this.isDestroyed) return;
265
+ this.isDestroyed = true;
266
+ this.stopTailing();
267
+ if (this.updateTimeout) {
268
+ clearTimeout(this.updateTimeout);
269
+ this.updateTimeout = null;
270
+ }
271
+ if (this.fileHandle) {
272
+ await this.fileHandle.close().catch(() => {});
273
+ this.fileHandle = null;
274
+ }
275
+ this.root.destroyRecursively();
276
+ }
277
+ }
@@ -0,0 +1,46 @@
1
+ import { expect, test, describe, mock, beforeEach } from "bun:test";
2
+ import "../test-setup.js";
3
+ import { createMockRenderer } from "../mock-factory.ts";
4
+ import { BoxRenderable } from "@opentui/core";
5
+
6
+ import { ToyboxView } from "./ToyboxView.js";
7
+
8
+ describe("ToyboxView", () => {
9
+ let mockRenderer: any;
10
+
11
+ beforeEach(() => {
12
+ mockRenderer = createMockRenderer();
13
+ });
14
+
15
+ test("should initialize with container", () => {
16
+ const onBack = mock(() => {});
17
+ const mockContainer = new BoxRenderable(mockRenderer, { id: "toybox-container" });
18
+ const view = new ToyboxView(mockRenderer, mockContainer, undefined, onBack);
19
+
20
+ expect(view["container"]).toBeDefined();
21
+ expect(view["toys"].length).toBeGreaterThan(0);
22
+ });
23
+
24
+ test("should have enable and disable methods", () => {
25
+ const mockContainer = new BoxRenderable(mockRenderer, { id: "toybox-container" });
26
+ const view = new ToyboxView(mockRenderer, mockContainer);
27
+
28
+ expect(typeof view.enable).toBe("function");
29
+ expect(typeof view.disable).toBe("function");
30
+ });
31
+
32
+ test("should have destroy method", () => {
33
+ const mockContainer = new BoxRenderable(mockRenderer, { id: "toybox-container" });
34
+ const view = new ToyboxView(mockRenderer, mockContainer);
35
+
36
+ expect(typeof view.destroy).toBe("function");
37
+ });
38
+
39
+ test("should create card renderables for toys", () => {
40
+ const mockContainer = new BoxRenderable(mockRenderer, { id: "toybox-container" });
41
+ const view = new ToyboxView(mockRenderer, mockContainer);
42
+
43
+ // Should have cards for each toy
44
+ expect(view["cardRenderables"].length).toBe(view["toys"].length);
45
+ });
46
+ });
@@ -0,0 +1,323 @@
1
+ import {
2
+ CliRenderer,
3
+ BoxRenderable,
4
+ TextRenderable,
5
+ ASCIIFontRenderable,
6
+ RGBA,
7
+ createTimeline,
8
+ RenderableEvents,
9
+ } from "@opentui/core";
10
+ import { THEME } from "../theme.js";
11
+ import { launchGameboy, isGameboyActive } from "../../games/gameboy/GameboyView.js";
12
+ import { launchSnake } from "../../games/snake/SnakeView.js";
13
+ import { HEADER_LINES, getLineColor } from "../common.js";
14
+ import { GameSidebarManager } from "../../games/GameSidebarManager.js";
15
+
16
+ /**
17
+ * Interface representing a "toy" in the toybox.
18
+ */
19
+ export interface Toy {
20
+ id: string;
21
+ title: string;
22
+ description: string;
23
+ color: string;
24
+ onClick?: () => void;
25
+ }
26
+
27
+ /**
28
+ * ToyboxView class representing a top-level view for Rick's inventions.
29
+ */
30
+ export class ToyboxView {
31
+ private keyHandler?: (key: any) => void;
32
+ private toys: Toy[] = [];
33
+ private cardRenderables: BoxRenderable[] = [];
34
+ private selectedIndex = -1;
35
+ private sidebarManager: GameSidebarManager;
36
+
37
+ constructor(
38
+ private renderer: CliRenderer,
39
+ private container: BoxRenderable,
40
+ private onSplitSnake?: () => void,
41
+ private onBack?: () => void
42
+ ) {
43
+ this.sidebarManager = new GameSidebarManager(renderer);
44
+ this.init();
45
+ }
46
+
47
+ private init() {
48
+ this.container.flexDirection = "column";
49
+ this.container.alignItems = "center";
50
+ this.container.justifyContent = "flex-start";
51
+ this.container.paddingTop = 1;
52
+ this.container.backgroundColor = RGBA.fromHex(THEME.bg);
53
+
54
+ const headerContainer = new BoxRenderable(this.renderer, {
55
+ id: "toybox-headerContainer",
56
+ flexDirection: "column",
57
+ alignItems: "center",
58
+ height: 10,
59
+ marginBottom: 1,
60
+ });
61
+
62
+ HEADER_LINES.forEach((line, i) => {
63
+ const color = getLineColor(i);
64
+ const textRenderable = new TextRenderable(this.renderer, {
65
+ id: `toybox-header-line-${i}`,
66
+ content: line.trimEnd(),
67
+ fg: color,
68
+ flexShrink: 0,
69
+ alignSelf: "center",
70
+ });
71
+ headerContainer.add(textRenderable);
72
+ });
73
+
74
+ const toyGrid = new BoxRenderable(this.renderer, {
75
+ id: "toybox-grid",
76
+ width: "100%",
77
+ flexGrow: 1,
78
+ flexDirection: "row",
79
+ flexWrap: "wrap",
80
+ justifyContent: "center",
81
+ alignItems: "flex-start",
82
+ padding: 2,
83
+ gap: 2,
84
+ });
85
+
86
+ this.populateToyGrid(toyGrid);
87
+
88
+ // Help text at the bottom
89
+ const helpText = new TextRenderable(this.renderer, {
90
+ id: "toybox-help",
91
+ content: "←→: Select | ENTER: Play | CTRL+S: Sessions | ESC: Back",
92
+ fg: THEME.dim,
93
+ alignSelf: "center",
94
+ marginTop: 1,
95
+ flexShrink: 0,
96
+ });
97
+
98
+ this.container.add(headerContainer);
99
+ this.container.add(toyGrid);
100
+ this.container.add(helpText);
101
+ }
102
+
103
+ public enable() {
104
+ if (!this.keyHandler) {
105
+ this.keyHandler = (key: any) => this.handleKey(key);
106
+ this.renderer.keyInput.on("keypress", this.keyHandler);
107
+ this.sidebarManager.enable();
108
+
109
+ // Select first item if nothing selected
110
+ if (this.selectedIndex === -1 && this.toys.length > 0) {
111
+ this.selectedIndex = 0;
112
+ this.updateSelection();
113
+ }
114
+ }
115
+ }
116
+
117
+ public disable() {
118
+ if (this.keyHandler) {
119
+ this.renderer.keyInput.off("keypress", this.keyHandler);
120
+ this.keyHandler = undefined;
121
+ }
122
+ this.sidebarManager.disable();
123
+ }
124
+
125
+ private handleKey(key: any) {
126
+ // Ignore input when GameBoy is active
127
+ if (isGameboyActive()) return;
128
+
129
+ if (key.name === "escape") {
130
+ if (this.onBack) this.onBack();
131
+ return;
132
+ }
133
+
134
+ // Handle sidebar toggle using generic manager
135
+ if (this.sidebarManager.handleKey(key)) {
136
+ return;
137
+ }
138
+
139
+ // Tab triggers split view
140
+ if (key.name === "tab") {
141
+ if (this.onSplitSnake) this.onSplitSnake();
142
+ return;
143
+ }
144
+
145
+ if (this.toys.length === 0) return;
146
+
147
+ if (key.name === "right") {
148
+ this.selectedIndex = (this.selectedIndex + 1) % this.toys.length;
149
+ this.updateSelection();
150
+ } else if (key.name === "left") {
151
+ this.selectedIndex = (this.selectedIndex - 1 + this.toys.length) % this.toys.length;
152
+ this.updateSelection();
153
+ } else if (key.name === "return" || key.name === "enter" || key.name === "space") {
154
+ if (this.selectedIndex !== -1 && this.toys[this.selectedIndex].onClick) {
155
+ this.toys[this.selectedIndex].onClick!();
156
+ }
157
+ }
158
+ }
159
+
160
+ private updateSelection() {
161
+ this.cardRenderables.forEach((card, i) => {
162
+ const isSelected = i === this.selectedIndex;
163
+ const toy = this.toys[i];
164
+ const targetBorder = isSelected ? RGBA.fromHex(THEME.accent) : RGBA.fromHex(THEME.darkAccent);
165
+ const targetBg = isSelected ? RGBA.fromHex(THEME.bg) : RGBA.fromHex(THEME.surface);
166
+
167
+ createTimeline()
168
+ .add(card.borderColor, {
169
+ r: targetBorder.r,
170
+ g: targetBorder.g,
171
+ b: targetBorder.b,
172
+ duration: 150,
173
+ ease: "outQuad",
174
+ })
175
+ .add(card.backgroundColor, {
176
+ r: targetBg.r,
177
+ g: targetBg.g,
178
+ b: targetBg.b,
179
+ duration: 150,
180
+ ease: "outQuad",
181
+ });
182
+ });
183
+ this.renderer.requestRender();
184
+ }
185
+
186
+ public destroy() {
187
+ this.disable();
188
+ }
189
+
190
+ private populateToyGrid(container: BoxRenderable) {
191
+ this.toys = [
192
+ {
193
+ id: "gameboy",
194
+ title: "GameBoy",
195
+ description: "Play retro GameBoy games",
196
+ color: THEME.blue,
197
+ onClick: () => {
198
+ this.disable();
199
+ launchGameboy(this.renderer, {
200
+ onExit: () => {
201
+ this.enable();
202
+ this.renderer.requestRender();
203
+ },
204
+ onSidebarRequest: () => this.sidebarManager.toggleSidebar(),
205
+ });
206
+ },
207
+ },
208
+ {
209
+ id: "snake",
210
+ title: "Snake",
211
+ description: "Pickle Snake! Eat to grow.",
212
+ color: THEME.accent,
213
+ onClick: () => {
214
+ this.disable();
215
+ launchSnake(this.renderer, () => {
216
+ this.enable();
217
+ this.renderer.requestRender();
218
+ }, {
219
+ onSplitRequest: (paused) => {
220
+ if (this.onSplitSnake) (this.onSplitSnake as any)(paused);
221
+ },
222
+ onSidebarRequest: () => this.sidebarManager.toggleSidebar(),
223
+ });
224
+ },
225
+ },
226
+ ];
227
+
228
+ this.cardRenderables = [];
229
+ this.toys.forEach((toy) => {
230
+ const card = this.createToyCard(toy);
231
+ this.cardRenderables.push(card);
232
+ container.add(card);
233
+ });
234
+ }
235
+
236
+ private createToyCard(toy: Toy): BoxRenderable {
237
+ const card = new BoxRenderable(this.renderer, {
238
+ id: `toy-card-${toy.id}`,
239
+ width: 32,
240
+ height: 14,
241
+ border: true,
242
+ borderColor: THEME.darkAccent,
243
+ backgroundColor: THEME.surface,
244
+ flexDirection: "column",
245
+ alignItems: "center",
246
+ justifyContent: "center",
247
+ padding: 1,
248
+ gap: 1,
249
+ });
250
+
251
+ const titleText = new ASCIIFontRenderable(this.renderer, {
252
+ id: `toy-title-${toy.id}`,
253
+ text: toy.title,
254
+ font: "tiny",
255
+ color: toy.color,
256
+ });
257
+
258
+ const descText = new TextRenderable(this.renderer, {
259
+ id: `toy-desc-${toy.id}`,
260
+ content: toy.description,
261
+ fg: THEME.dim,
262
+ marginTop: 1,
263
+ });
264
+
265
+ card.add(titleText);
266
+ card.add(descText);
267
+
268
+ const normalBorder = RGBA.fromHex(THEME.darkAccent);
269
+ const hoverBorder = RGBA.fromHex(THEME.accent);
270
+ const normalBg = RGBA.fromHex(THEME.surface);
271
+ const hoverBg = RGBA.fromHex(THEME.bg);
272
+
273
+ card.onMouseOver = () => {
274
+ createTimeline()
275
+ .add(card.borderColor, {
276
+ r: hoverBorder.r,
277
+ g: hoverBorder.g,
278
+ b: hoverBorder.b,
279
+ duration: 150,
280
+ ease: "outQuad",
281
+ })
282
+ .add(card.backgroundColor, {
283
+ r: hoverBg.r,
284
+ g: hoverBg.g,
285
+ b: hoverBg.b,
286
+ duration: 150,
287
+ ease: "outQuad",
288
+ });
289
+ };
290
+
291
+ card.onMouseOut = () => {
292
+ createTimeline()
293
+ .add(card.borderColor, {
294
+ r: normalBorder.r,
295
+ g: normalBorder.g,
296
+ b: normalBorder.b,
297
+ duration: 200,
298
+ ease: "outQuad",
299
+ })
300
+ .add(card.backgroundColor, {
301
+ r: normalBg.r,
302
+ g: normalBg.g,
303
+ b: normalBg.b,
304
+ duration: 200,
305
+ ease: "outQuad",
306
+ });
307
+ };
308
+
309
+ if (toy.onClick) {
310
+ const originalOnMouse = card.onMouse;
311
+ card.onMouse = (event: any) => {
312
+ if (event.type === "click" || event.type === "up") {
313
+ toy.onClick!();
314
+ }
315
+ if (originalOnMouse) {
316
+ originalOnMouse(event);
317
+ }
318
+ };
319
+ }
320
+
321
+ return card;
322
+ }
323
+ }