pi-extensions 0.1.9

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 (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Tetris game extension - play with /tetris
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+
8
+ const BOARD_WIDTH = 10;
9
+ const BOARD_HEIGHT = 20;
10
+ const TICK_MS = 50;
11
+ const CELL_WIDTH = 2;
12
+ const PREVIEW_COUNT = 3;
13
+
14
+ const TETRIS_SAVE_TYPE = "tetris-save";
15
+
16
+ // Tetromino definitions: each piece has rotations as [row][col] offsets from pivot
17
+ type Piece = { shape: number[][][]; color: string };
18
+
19
+ const PIECES: Record<string, Piece> = {
20
+ I: {
21
+ shape: [
22
+ [[0, -1], [0, 0], [0, 1], [0, 2]],
23
+ [[-1, 0], [0, 0], [1, 0], [2, 0]],
24
+ [[0, -1], [0, 0], [0, 1], [0, 2]],
25
+ [[-1, 0], [0, 0], [1, 0], [2, 0]],
26
+ ],
27
+ color: "36", // cyan
28
+ },
29
+ O: {
30
+ shape: [
31
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
32
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
33
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
34
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
35
+ ],
36
+ color: "33", // yellow
37
+ },
38
+ T: {
39
+ shape: [
40
+ [[0, -1], [0, 0], [0, 1], [-1, 0]],
41
+ [[-1, 0], [0, 0], [1, 0], [0, 1]],
42
+ [[0, -1], [0, 0], [0, 1], [1, 0]],
43
+ [[-1, 0], [0, 0], [1, 0], [0, -1]],
44
+ ],
45
+ color: "35", // magenta
46
+ },
47
+ S: {
48
+ shape: [
49
+ [[0, 0], [0, 1], [-1, 1], [-1, 2]],
50
+ [[-1, 0], [0, 0], [0, 1], [1, 1]],
51
+ [[0, 0], [0, 1], [-1, 1], [-1, 2]],
52
+ [[-1, 0], [0, 0], [0, 1], [1, 1]],
53
+ ],
54
+ color: "32", // green
55
+ },
56
+ Z: {
57
+ shape: [
58
+ [[-1, 0], [-1, 1], [0, 1], [0, 2]],
59
+ [[0, 0], [-1, 0], [-1, 1], [-2, 1]],
60
+ [[-1, 0], [-1, 1], [0, 1], [0, 2]],
61
+ [[0, 0], [-1, 0], [-1, 1], [-2, 1]],
62
+ ],
63
+ color: "31", // red
64
+ },
65
+ J: {
66
+ shape: [
67
+ [[-1, -1], [0, -1], [0, 0], [0, 1]],
68
+ [[-1, 0], [-1, 1], [0, 0], [1, 0]],
69
+ [[0, -1], [0, 0], [0, 1], [1, 1]],
70
+ [[-1, 0], [0, 0], [1, 0], [1, -1]],
71
+ ],
72
+ color: "34", // blue
73
+ },
74
+ L: {
75
+ shape: [
76
+ [[0, -1], [0, 0], [0, 1], [-1, 1]],
77
+ [[-1, 0], [0, 0], [1, 0], [1, 1]],
78
+ [[0, -1], [0, 0], [0, 1], [1, -1]],
79
+ [[-1, -1], [-1, 0], [0, 0], [1, 0]],
80
+ ],
81
+ color: "38;5;208", // orange
82
+ },
83
+ };
84
+
85
+ const PIECE_NAMES = Object.keys(PIECES);
86
+
87
+ interface FallingPiece {
88
+ type: string;
89
+ rotation: number;
90
+ row: number;
91
+ col: number;
92
+ }
93
+
94
+ interface GameState {
95
+ board: (string | null)[][]; // color code or null for empty
96
+ current: FallingPiece;
97
+ queue: string[];
98
+ held: string | null;
99
+ canHold: boolean;
100
+ score: number;
101
+ lines: number;
102
+ level: number;
103
+ highScore: number;
104
+ gameOver: boolean;
105
+ tickCounter: number;
106
+ lockDelay: number;
107
+ clearingRows: number[];
108
+ clearAnimTicks: number;
109
+ }
110
+
111
+ const color = (code: string, text: string): string => `\x1b[${code}m${text}\x1b[0m`;
112
+ const dim = (text: string): string => color("2", text);
113
+ const accent = (text: string): string => color("33;1", text);
114
+ const bold = (text: string): string => color("1", text);
115
+
116
+ const randomPiece = (): string => PIECE_NAMES[Math.floor(Math.random() * PIECE_NAMES.length)];
117
+
118
+ const generateBag = (): string[] => {
119
+ const bag = [...PIECE_NAMES];
120
+ for (let i = bag.length - 1; i > 0; i--) {
121
+ const j = Math.floor(Math.random() * (i + 1));
122
+ [bag[i], bag[j]] = [bag[j], bag[i]];
123
+ }
124
+ return bag;
125
+ };
126
+
127
+ const createEmptyBoard = (): (string | null)[][] =>
128
+ Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(null));
129
+
130
+ const createInitialState = (highScore = 0): GameState => {
131
+ const queue = [...generateBag(), ...generateBag()];
132
+ const current: FallingPiece = {
133
+ type: queue.shift()!,
134
+ rotation: 0,
135
+ row: 0,
136
+ col: Math.floor(BOARD_WIDTH / 2),
137
+ };
138
+ return {
139
+ board: createEmptyBoard(),
140
+ current,
141
+ queue,
142
+ held: null,
143
+ canHold: true,
144
+ score: 0,
145
+ lines: 0,
146
+ level: 1,
147
+ highScore,
148
+ gameOver: false,
149
+ tickCounter: 0,
150
+ lockDelay: 0,
151
+ clearingRows: [],
152
+ clearAnimTicks: 0,
153
+ };
154
+ };
155
+
156
+ const cloneState = (state: GameState): GameState => ({
157
+ ...state,
158
+ board: state.board.map((row) => [...row]),
159
+ current: { ...state.current },
160
+ queue: [...state.queue],
161
+ clearingRows: [...state.clearingRows],
162
+ });
163
+
164
+ const getPieceCells = (piece: FallingPiece): [number, number][] => {
165
+ const shape = PIECES[piece.type].shape[piece.rotation];
166
+ return shape.map(([dr, dc]) => [piece.row + dr, piece.col + dc]);
167
+ };
168
+
169
+ const isValidPosition = (board: (string | null)[][], piece: FallingPiece): boolean => {
170
+ const cells = getPieceCells(piece);
171
+ for (const [r, c] of cells) {
172
+ // Allow cells above board (r < 0) - they're in the spawn zone
173
+ if (r >= BOARD_HEIGHT || c < 0 || c >= BOARD_WIDTH) return false;
174
+ // Only check board collision for cells that are on the board
175
+ if (r >= 0 && board[r][c] !== null) return false;
176
+ }
177
+ return true;
178
+ };
179
+
180
+ const getDropSpeed = (level: number): number => {
181
+ // Frames between drops, decreases with level
182
+ return Math.max(2, 20 - (level - 1) * 2);
183
+ };
184
+
185
+ class TetrisComponent {
186
+ private state: GameState;
187
+ private interval: ReturnType<typeof setInterval> | null = null;
188
+ private onClose: () => void;
189
+ private onSave: (state: GameState | null) => void;
190
+ private tui: { requestRender: () => void };
191
+ private cachedLines: string[] = [];
192
+ private cachedWidth = 0;
193
+ private version = 0;
194
+ private cachedVersion = -1;
195
+ private paused: boolean;
196
+
197
+ constructor(
198
+ tui: { requestRender: () => void },
199
+ onClose: () => void,
200
+ onSave: (state: GameState | null) => void,
201
+ savedState?: GameState,
202
+ ) {
203
+ this.tui = tui;
204
+ this.onClose = onClose;
205
+ this.onSave = onSave;
206
+ this.state = savedState ? cloneState(savedState) : createInitialState();
207
+ this.paused = false;
208
+ this.startLoop();
209
+ }
210
+
211
+ private startLoop(): void {
212
+ if (this.interval) return;
213
+ this.interval = setInterval(() => this.tick(), TICK_MS);
214
+ }
215
+
216
+ private stopLoop(): void {
217
+ if (this.interval) {
218
+ clearInterval(this.interval);
219
+ this.interval = null;
220
+ }
221
+ }
222
+
223
+ private tick(): void {
224
+ if (this.paused || this.state.gameOver) return;
225
+
226
+ // Handle line clear animation
227
+ if (this.state.clearingRows.length > 0) {
228
+ this.state.clearAnimTicks++;
229
+ if (this.state.clearAnimTicks >= 6) {
230
+ this.finalizeClear();
231
+ }
232
+ this.version++;
233
+ this.tui.requestRender();
234
+ return;
235
+ }
236
+
237
+ this.state.tickCounter++;
238
+ const dropSpeed = getDropSpeed(this.state.level);
239
+
240
+ if (this.state.tickCounter >= dropSpeed) {
241
+ this.state.tickCounter = 0;
242
+ if (!this.tryMove(1, 0)) {
243
+ this.state.lockDelay++;
244
+ if (this.state.lockDelay >= 10) {
245
+ this.lockPiece();
246
+ }
247
+ } else {
248
+ this.state.lockDelay = 0;
249
+ }
250
+ }
251
+
252
+ this.version++;
253
+ this.tui.requestRender();
254
+ }
255
+
256
+ private tryMove(dr: number, dc: number): boolean {
257
+ const newPiece = { ...this.state.current, row: this.state.current.row + dr, col: this.state.current.col + dc };
258
+ if (isValidPosition(this.state.board, newPiece)) {
259
+ this.state.current = newPiece;
260
+ return true;
261
+ }
262
+ return false;
263
+ }
264
+
265
+ private tryRotate(dir: 1 | -1): boolean {
266
+ const newRotation = (this.state.current.rotation + dir + 4) % 4;
267
+ const newPiece = { ...this.state.current, rotation: newRotation };
268
+
269
+ // Try basic rotation
270
+ if (isValidPosition(this.state.board, newPiece)) {
271
+ this.state.current = newPiece;
272
+ this.state.lockDelay = 0;
273
+ return true;
274
+ }
275
+
276
+ // Wall kicks
277
+ const kicks = [[0, -1], [0, 1], [0, -2], [0, 2], [-1, 0], [1, 0]];
278
+ for (const [dr, dc] of kicks) {
279
+ const kickedPiece = { ...newPiece, row: newPiece.row + dr, col: newPiece.col + dc };
280
+ if (isValidPosition(this.state.board, kickedPiece)) {
281
+ this.state.current = kickedPiece;
282
+ this.state.lockDelay = 0;
283
+ return true;
284
+ }
285
+ }
286
+ return false;
287
+ }
288
+
289
+ private hardDrop(): void {
290
+ let dropDistance = 0;
291
+ while (this.tryMove(1, 0)) {
292
+ dropDistance++;
293
+ }
294
+ this.state.score += dropDistance * 2;
295
+ this.lockPiece();
296
+ }
297
+
298
+ private lockPiece(): void {
299
+ const cells = getPieceCells(this.state.current);
300
+ const pieceColor = PIECES[this.state.current.type].color;
301
+
302
+ for (const [r, c] of cells) {
303
+ if (r >= 0 && r < BOARD_HEIGHT && c >= 0 && c < BOARD_WIDTH) {
304
+ this.state.board[r][c] = pieceColor;
305
+ }
306
+ }
307
+
308
+ // Check for line clears
309
+ const fullRows: number[] = [];
310
+ for (let r = 0; r < BOARD_HEIGHT; r++) {
311
+ if (this.state.board[r].every((cell) => cell !== null)) {
312
+ fullRows.push(r);
313
+ }
314
+ }
315
+
316
+ if (fullRows.length > 0) {
317
+ this.state.clearingRows = fullRows;
318
+ this.state.clearAnimTicks = 0;
319
+ } else {
320
+ this.spawnNext();
321
+ }
322
+ }
323
+
324
+ private finalizeClear(): void {
325
+ const clearedCount = this.state.clearingRows.length;
326
+
327
+ // Remove cleared rows
328
+ this.state.board = this.state.board.filter((_, i) => !this.state.clearingRows.includes(i));
329
+
330
+ // Add new empty rows at top
331
+ for (let i = 0; i < clearedCount; i++) {
332
+ this.state.board.unshift(Array(BOARD_WIDTH).fill(null));
333
+ }
334
+
335
+ // Scoring: 100, 300, 500, 800 for 1-4 lines
336
+ const lineScores = [0, 100, 300, 500, 800];
337
+ this.state.score += (lineScores[clearedCount] || 800) * this.state.level;
338
+ this.state.lines += clearedCount;
339
+
340
+ // Level up every 10 lines
341
+ const newLevel = Math.floor(this.state.lines / 10) + 1;
342
+ if (newLevel > this.state.level) {
343
+ this.state.level = newLevel;
344
+ }
345
+
346
+ this.state.clearingRows = [];
347
+ this.state.clearAnimTicks = 0;
348
+ this.spawnNext();
349
+ }
350
+
351
+ private spawnNext(): void {
352
+ // Ensure queue has enough pieces
353
+ while (this.state.queue.length < PREVIEW_COUNT + 1) {
354
+ this.state.queue.push(...generateBag());
355
+ }
356
+
357
+ this.state.current = {
358
+ type: this.state.queue.shift()!,
359
+ rotation: 0,
360
+ row: 0,
361
+ col: Math.floor(BOARD_WIDTH / 2),
362
+ };
363
+ this.state.lockDelay = 0;
364
+ this.state.canHold = true;
365
+
366
+ // Check game over
367
+ if (!isValidPosition(this.state.board, this.state.current)) {
368
+ this.state.gameOver = true;
369
+ if (this.state.score > this.state.highScore) {
370
+ this.state.highScore = this.state.score;
371
+ }
372
+ }
373
+ }
374
+
375
+ private holdPiece(): void {
376
+ if (!this.state.canHold) return;
377
+
378
+ const currentType = this.state.current.type;
379
+ if (this.state.held === null) {
380
+ this.state.held = currentType;
381
+ this.spawnNext();
382
+ } else {
383
+ const heldType = this.state.held;
384
+ this.state.held = currentType;
385
+ this.state.current = {
386
+ type: heldType,
387
+ rotation: 0,
388
+ row: 0,
389
+ col: Math.floor(BOARD_WIDTH / 2),
390
+ };
391
+ }
392
+ this.state.canHold = false;
393
+ }
394
+
395
+ handleInput(data: string): void {
396
+ if (this.state.gameOver) {
397
+ if (matchesKey(data, "r") || data === "r" || data === "R") {
398
+ this.state = createInitialState(this.state.highScore);
399
+ this.version++;
400
+ this.tui.requestRender();
401
+ } else if (matchesKey(data, "q") || data === "q" || data === "Q" || matchesKey(data, "escape")) {
402
+ this.dispose();
403
+ this.onSave(null);
404
+ this.onClose();
405
+ }
406
+ return;
407
+ }
408
+
409
+ if (matchesKey(data, "p") || data === "p" || data === "P") {
410
+ this.paused = !this.paused;
411
+ this.version++;
412
+ this.tui.requestRender();
413
+ return;
414
+ }
415
+
416
+ if (matchesKey(data, "escape")) {
417
+ this.dispose();
418
+ this.onSave(this.state);
419
+ this.onClose();
420
+ return;
421
+ }
422
+
423
+ if (matchesKey(data, "q") || data === "q" || data === "Q") {
424
+ this.dispose();
425
+ this.onSave(null);
426
+ this.onClose();
427
+ return;
428
+ }
429
+
430
+ if (this.paused) {
431
+ this.paused = false;
432
+ this.version++;
433
+ this.tui.requestRender();
434
+ return;
435
+ }
436
+
437
+ // Movement - left
438
+ if (matchesKey(data, "left") || data === "a" || data === "A" || data === "h" || data === "H") {
439
+ this.tryMove(0, -1);
440
+ }
441
+ // Movement - right
442
+ else if (matchesKey(data, "right") || data === "d" || data === "D" || data === "l" || data === "L") {
443
+ this.tryMove(0, 1);
444
+ }
445
+ // Soft drop - single step per keypress
446
+ else if (matchesKey(data, "down") || data === "s" || data === "S" || data === "j" || data === "J") {
447
+ if (this.tryMove(1, 0)) {
448
+ this.state.score += 1;
449
+ this.state.lockDelay = 0;
450
+ }
451
+ }
452
+ // Rotate clockwise
453
+ else if (matchesKey(data, "up") || data === "w" || data === "W" || data === "k" || data === "K") {
454
+ this.tryRotate(1);
455
+ }
456
+ // Rotate counter-clockwise
457
+ else if (data === "z" || data === "Z" || data === "x" || data === "X") {
458
+ this.tryRotate(-1);
459
+ }
460
+ // Hard drop
461
+ else if (data === " ") {
462
+ this.hardDrop();
463
+ }
464
+ // Hold piece
465
+ else if (data === "c" || data === "C") {
466
+ this.holdPiece();
467
+ }
468
+
469
+ this.version++;
470
+ this.tui.requestRender();
471
+ }
472
+
473
+ render(width: number, _height: number): string[] {
474
+ if (this.cachedVersion === this.version && this.cachedWidth === width) {
475
+ return this.cachedLines;
476
+ }
477
+
478
+ const lines: string[] = [];
479
+ const boardWidth = BOARD_WIDTH * CELL_WIDTH;
480
+ const sideWidth = 12;
481
+ const totalWidth = boardWidth + 2 + sideWidth + 3;
482
+
483
+ // Title
484
+ lines.push(this.padLine(bold("╔═══ TETRIS ═══╗"), width));
485
+ lines.push("");
486
+
487
+ // Build board with current piece
488
+ const displayBoard: (string | null)[][] = this.state.board.map((row) => [...row]);
489
+
490
+ // Add ghost piece
491
+ let ghostRow = this.state.current.row;
492
+ const ghostPiece = { ...this.state.current };
493
+ while (isValidPosition(this.state.board, { ...ghostPiece, row: ghostRow + 1 })) {
494
+ ghostRow++;
495
+ }
496
+ ghostPiece.row = ghostRow;
497
+ if (ghostRow !== this.state.current.row) {
498
+ const ghostCells = getPieceCells(ghostPiece);
499
+ for (const [r, c] of ghostCells) {
500
+ if (r >= 0 && r < BOARD_HEIGHT && c >= 0 && c < BOARD_WIDTH && displayBoard[r][c] === null) {
501
+ displayBoard[r][c] = "ghost";
502
+ }
503
+ }
504
+ }
505
+
506
+ // Add current piece
507
+ const currentCells = getPieceCells(this.state.current);
508
+ const currentColor = PIECES[this.state.current.type].color;
509
+ for (const [r, c] of currentCells) {
510
+ if (r >= 0 && r < BOARD_HEIGHT && c >= 0 && c < BOARD_WIDTH) {
511
+ displayBoard[r][c] = currentColor;
512
+ }
513
+ }
514
+
515
+ // Header
516
+ const scoreStr = `Score: ${this.state.score.toString().padStart(6)}`;
517
+ const levelStr = `Lv ${this.state.level}`;
518
+ const linesStr = `Lines: ${this.state.lines}`;
519
+
520
+ lines.push(this.padLine(`┌${"─".repeat(boardWidth)}┐ ${dim("HOLD")}`, width));
521
+
522
+ // Render held piece
523
+ const heldPreview = this.renderMiniPiece(this.state.held, !this.state.canHold);
524
+
525
+ // Render preview pieces
526
+ const previews = this.state.queue.slice(0, PREVIEW_COUNT).map((type) => this.renderMiniPiece(type, false));
527
+
528
+ // Main board rows
529
+ for (let r = 0; r < BOARD_HEIGHT; r++) {
530
+ let rowStr = "│";
531
+ for (let c = 0; c < BOARD_WIDTH; c++) {
532
+ const cell = displayBoard[r][c];
533
+ const clearing = this.state.clearingRows.includes(r);
534
+
535
+ if (clearing) {
536
+ // Flash animation
537
+ const flash = this.state.clearAnimTicks % 2 === 0;
538
+ rowStr += flash ? color("47", " ") : " ";
539
+ } else if (cell === null) {
540
+ rowStr += dim("· ");
541
+ } else if (cell === "ghost") {
542
+ rowStr += dim("░░");
543
+ } else {
544
+ rowStr += color(cell, "██");
545
+ }
546
+ }
547
+ rowStr += "│";
548
+
549
+ // Side panel
550
+ let sideContent = "";
551
+ if (r === 0) {
552
+ sideContent = heldPreview[0] || "";
553
+ } else if (r === 1) {
554
+ sideContent = heldPreview[1] || "";
555
+ } else if (r === 3) {
556
+ sideContent = dim("NEXT");
557
+ } else if (r >= 4 && r < 4 + PREVIEW_COUNT * 3) {
558
+ const previewIdx = Math.floor((r - 4) / 3);
559
+ const previewRow = (r - 4) % 3;
560
+ if (previewIdx < previews.length && previewRow < 2) {
561
+ sideContent = previews[previewIdx][previewRow] || "";
562
+ }
563
+ } else if (r === 14) {
564
+ sideContent = scoreStr;
565
+ } else if (r === 15) {
566
+ sideContent = levelStr;
567
+ } else if (r === 16) {
568
+ sideContent = linesStr;
569
+ } else if (r === 18) {
570
+ sideContent = `Hi: ${this.state.highScore}`;
571
+ }
572
+
573
+ lines.push(this.padLine(`${rowStr} ${sideContent}`, width));
574
+ }
575
+
576
+ lines.push(this.padLine(`└${"─".repeat(boardWidth)}┘`, width));
577
+
578
+ // Controls
579
+ lines.push("");
580
+ if (this.paused) {
581
+ lines.push(this.padLine(`${accent("PAUSED")} - Press any key to resume`, width));
582
+ } else if (this.state.gameOver) {
583
+ lines.push(this.padLine(color("31;1", "GAME OVER") + ` - ${accent("R")} restart, ${accent("Q")} quit`, width));
584
+ } else {
585
+ lines.push(this.padLine(`←→/AD move │ ↑/W rotate │ ↓/S soft drop │ ${accent("SPACE")} hard drop`, width));
586
+ lines.push(this.padLine(`${accent("C")} hold │ ${accent("P")} pause │ ${accent("ESC")} save & quit`, width));
587
+ }
588
+
589
+ this.cachedLines = lines;
590
+ this.cachedWidth = width;
591
+ this.cachedVersion = this.version;
592
+
593
+ return lines;
594
+ }
595
+
596
+ private renderMiniPiece(type: string | null, faded: boolean): string[] {
597
+ if (type === null) return [" ", " "];
598
+
599
+ const piece = PIECES[type];
600
+ const shape = piece.shape[0];
601
+ const cells = new Set(shape.map(([r, c]) => `${r},${c}`));
602
+
603
+ const rows: string[] = [];
604
+ for (let r = -1; r <= 1; r++) {
605
+ let rowStr = "";
606
+ for (let c = -1; c <= 2; c++) {
607
+ if (cells.has(`${r},${c}`)) {
608
+ rowStr += faded ? dim("▓▓") : color(piece.color, "██");
609
+ } else {
610
+ rowStr += " ";
611
+ }
612
+ }
613
+ rows.push(rowStr);
614
+ }
615
+ return rows.slice(0, 2);
616
+ }
617
+
618
+ private padLine(line: string, width: number): string {
619
+ const truncated = truncateToWidth(line, width);
620
+ const padding = Math.max(0, width - visibleWidth(truncated));
621
+ return truncated + " ".repeat(padding);
622
+ }
623
+
624
+ dispose(): void {
625
+ this.stopLoop();
626
+ }
627
+ }
628
+
629
+ export default function (pi: ExtensionAPI) {
630
+ const runGame = async (_args: string, ctx: ExtensionCommandContext) => {
631
+ if (!ctx.hasUI) {
632
+ ctx.ui.notify("Tetris requires interactive mode", "error");
633
+ return;
634
+ }
635
+
636
+ const entries = ctx.sessionManager.getEntries();
637
+ let savedState: GameState | undefined;
638
+ for (let i = entries.length - 1; i >= 0; i--) {
639
+ const entry = entries[i];
640
+ if (entry.type === "custom" && entry.customType === TETRIS_SAVE_TYPE) {
641
+ savedState = entry.data as GameState;
642
+ break;
643
+ }
644
+ }
645
+
646
+ await ctx.ui.custom((tui, _theme, _kb, done) => {
647
+ return new TetrisComponent(
648
+ tui,
649
+ () => done(undefined),
650
+ (state) => {
651
+ pi.appendEntry(TETRIS_SAVE_TYPE, state);
652
+ },
653
+ savedState,
654
+ );
655
+ });
656
+ };
657
+
658
+ pi.registerCommand("tetris", {
659
+ description: "Play Tetris!",
660
+ handler: runGame,
661
+ });
662
+ }
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-01-13
4
+ - Initial release.