arbiter-ai 1.0.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 (51) hide show
  1. package/README.md +41 -0
  2. package/assets/jerom_16x16.png +0 -0
  3. package/dist/arbiter.d.ts +43 -0
  4. package/dist/arbiter.js +486 -0
  5. package/dist/context-analyzer.d.ts +15 -0
  6. package/dist/context-analyzer.js +603 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +165 -0
  9. package/dist/orchestrator.d.ts +31 -0
  10. package/dist/orchestrator.js +227 -0
  11. package/dist/router.d.ts +187 -0
  12. package/dist/router.js +1135 -0
  13. package/dist/router.test.d.ts +15 -0
  14. package/dist/router.test.js +95 -0
  15. package/dist/session-persistence.d.ts +9 -0
  16. package/dist/session-persistence.js +63 -0
  17. package/dist/session-persistence.test.d.ts +1 -0
  18. package/dist/session-persistence.test.js +165 -0
  19. package/dist/sound.d.ts +31 -0
  20. package/dist/sound.js +50 -0
  21. package/dist/state.d.ts +72 -0
  22. package/dist/state.js +107 -0
  23. package/dist/state.test.d.ts +1 -0
  24. package/dist/state.test.js +194 -0
  25. package/dist/test-headless.d.ts +1 -0
  26. package/dist/test-headless.js +155 -0
  27. package/dist/tui/index.d.ts +14 -0
  28. package/dist/tui/index.js +17 -0
  29. package/dist/tui/layout.d.ts +30 -0
  30. package/dist/tui/layout.js +200 -0
  31. package/dist/tui/render.d.ts +57 -0
  32. package/dist/tui/render.js +266 -0
  33. package/dist/tui/scene.d.ts +64 -0
  34. package/dist/tui/scene.js +366 -0
  35. package/dist/tui/screens/CharacterSelect-termkit.d.ts +18 -0
  36. package/dist/tui/screens/CharacterSelect-termkit.js +216 -0
  37. package/dist/tui/screens/ForestIntro-termkit.d.ts +15 -0
  38. package/dist/tui/screens/ForestIntro-termkit.js +856 -0
  39. package/dist/tui/screens/GitignoreCheck-termkit.d.ts +14 -0
  40. package/dist/tui/screens/GitignoreCheck-termkit.js +185 -0
  41. package/dist/tui/screens/TitleScreen-termkit.d.ts +14 -0
  42. package/dist/tui/screens/TitleScreen-termkit.js +132 -0
  43. package/dist/tui/screens/index.d.ts +9 -0
  44. package/dist/tui/screens/index.js +10 -0
  45. package/dist/tui/tileset.d.ts +97 -0
  46. package/dist/tui/tileset.js +237 -0
  47. package/dist/tui/tui-termkit.d.ts +34 -0
  48. package/dist/tui/tui-termkit.js +2602 -0
  49. package/dist/tui/types.d.ts +41 -0
  50. package/dist/tui/types.js +4 -0
  51. package/package.json +71 -0
@@ -0,0 +1,2602 @@
1
+ /**
2
+ * Terminal-Kit based TUI using Strategy 5 (Minimal Redraws)
3
+ *
4
+ * This replaces the Ink-based TUI with a terminal-kit implementation
5
+ * that uses minimal redraws for flicker-free animation and input handling.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { Fzf } from 'fzf';
10
+ import termKit from 'terminal-kit';
11
+ import { playSfx } from '../sound.js';
12
+ import { toRoman } from '../state.js';
13
+ import { createInitialSceneState, createScene, renderScene } from './scene.js';
14
+ import { CHAR_HEIGHT, compositeTiles, extractTile, loadTileset, RESET, renderTile, TILE, TILE_SIZE, } from './tileset.js';
15
+ // ============================================================================
16
+ // Constants
17
+ // ============================================================================
18
+ // Scene dimensions
19
+ const SCENE_WIDTH = 7;
20
+ const SCENE_HEIGHT = 6;
21
+ const TILE_AREA_WIDTH = SCENE_WIDTH * TILE_SIZE; // 112 chars
22
+ const TILE_AREA_HEIGHT = SCENE_HEIGHT * CHAR_HEIGHT; // 48 rows
23
+ // Filler tile cache (for grass/trees above and below scene)
24
+ const fillerRowCache = new Map();
25
+ // Animation
26
+ const ANIMATION_INTERVAL = 250; // ms
27
+ // Debug log file (temporary, cleared each session)
28
+ const DEBUG_LOG_PATH = path.join(process.cwd(), '.claude', 'arbiter.tmp.log');
29
+ // Input area
30
+ const MAX_INPUT_LINES = 5; // Maximum visible lines in input area
31
+ const SCROLL_PADDING = 10; // Extra rows to scroll past content
32
+ // Colors
33
+ const COLORS = {
34
+ human: '\x1b[32m', // green
35
+ arbiter: '\x1b[33m', // yellow
36
+ orchestrator: '\x1b[36m', // cyan
37
+ system: '\x1b[2;3m', // dim + italic for narrator/system messages
38
+ reset: '\x1b[0m',
39
+ dim: '\x1b[2m',
40
+ bold: '\x1b[1m',
41
+ };
42
+ // Dialogue box tile indices (for message panels)
43
+ const DIALOGUE_TILES = {
44
+ TOP_LEFT: 38,
45
+ TOP_RIGHT: 39,
46
+ BOTTOM_LEFT: 48,
47
+ BOTTOM_RIGHT: 49,
48
+ };
49
+ // Terminal-Kit instance
50
+ const term = termKit.terminal;
51
+ // ============================================================================
52
+ // Layout Calculations
53
+ // ============================================================================
54
+ /**
55
+ * Calculate how many lines the input buffer needs when wrapped
56
+ * @param text The input text (may contain newlines)
57
+ * @param width Available width for text (excluding prompt)
58
+ * @returns Number of lines needed (displayLines capped at MAX_INPUT_LINES, totalLines is actual count)
59
+ */
60
+ function calculateInputLines(text, width) {
61
+ if (!text || width <= 0)
62
+ return { displayLines: 1, totalLines: 1 };
63
+ // Split by actual newlines first
64
+ const paragraphs = text.split('\n');
65
+ let totalLines = 0;
66
+ for (const para of paragraphs) {
67
+ if (para.length === 0) {
68
+ totalLines += 1; // Empty line
69
+ }
70
+ else {
71
+ // Calculate wrapped lines for this paragraph
72
+ totalLines += Math.ceil(para.length / width);
73
+ }
74
+ }
75
+ return {
76
+ displayLines: Math.min(Math.max(1, totalLines), MAX_INPUT_LINES),
77
+ totalLines: Math.max(1, totalLines),
78
+ };
79
+ }
80
+ function getLayout(inputText = '') {
81
+ let width = 180;
82
+ let height = 50;
83
+ if (typeof term.width === 'number' && Number.isFinite(term.width) && term.width > 0) {
84
+ width = term.width;
85
+ }
86
+ if (typeof term.height === 'number' && Number.isFinite(term.height) && term.height > 0) {
87
+ height = term.height;
88
+ }
89
+ // Left side: tile scene - vertically centered
90
+ const tileAreaX = 1;
91
+ const tileAreaY = Math.max(1, Math.floor((height - TILE_AREA_HEIGHT) / 2));
92
+ const fillerRowsAbove = tileAreaY - 1;
93
+ const fillerRowsBelow = Math.max(0, height - (tileAreaY + TILE_AREA_HEIGHT));
94
+ // Right side: chat, status, input
95
+ const chatAreaX = TILE_AREA_WIDTH + 3; // Leave 1 col gap
96
+ const chatAreaWidth = Math.max(40, width - chatAreaX - 1);
97
+ // Calculate dynamic input area height based on content
98
+ const inputTextWidth = chatAreaWidth - 3; // -3 for prompt "> " and cursor space
99
+ const { displayLines: inputLines } = calculateInputLines(inputText, inputTextWidth);
100
+ // Input area at bottom, status bar above it, context bar above that, chat fills remaining space
101
+ const inputY = height - inputLines + 1; // +1 because 1-indexed
102
+ const statusY = inputY - 1;
103
+ const contextY = statusY - 1; // Context bar above status
104
+ const chatAreaY = 1;
105
+ const chatAreaHeight = contextY - 1; // Chat goes up to context bar
106
+ return {
107
+ width,
108
+ height,
109
+ tileArea: {
110
+ x: tileAreaX,
111
+ y: tileAreaY,
112
+ width: TILE_AREA_WIDTH,
113
+ height: TILE_AREA_HEIGHT,
114
+ fillerRowsAbove,
115
+ fillerRowsBelow,
116
+ },
117
+ chatArea: {
118
+ x: chatAreaX,
119
+ y: chatAreaY,
120
+ width: chatAreaWidth,
121
+ height: chatAreaHeight,
122
+ },
123
+ contextBar: {
124
+ x: chatAreaX,
125
+ y: contextY,
126
+ width: chatAreaWidth,
127
+ },
128
+ statusBar: {
129
+ x: chatAreaX,
130
+ y: statusY,
131
+ width: chatAreaWidth,
132
+ },
133
+ inputArea: {
134
+ x: chatAreaX,
135
+ y: inputY,
136
+ width: chatAreaWidth,
137
+ height: inputLines,
138
+ },
139
+ };
140
+ }
141
+ // ============================================================================
142
+ // Factory Function
143
+ // ============================================================================
144
+ /**
145
+ * Creates a TUI instance using terminal-kit with Strategy 5 (minimal redraws)
146
+ */
147
+ export function createTUI(appState, selectedCharacter) {
148
+ // Internal TUI state
149
+ const state = {
150
+ tileset: null,
151
+ sceneState: {
152
+ ...createInitialSceneState(),
153
+ selectedCharacter: selectedCharacter ?? TILE.HUMAN_1,
154
+ humanCol: 0, // Start at leftmost position for entry animation
155
+ scrollVisible: !!appState.requirementsPath,
156
+ },
157
+ messages: [],
158
+ scrollOffset: 0,
159
+ inputBuffer: '',
160
+ cursorPos: 0,
161
+ mode: 'INSERT', // Start in insert mode
162
+ arbiterContextPercent: 0,
163
+ orchestratorContextPercent: null,
164
+ currentTool: null,
165
+ toolCallCount: 0,
166
+ lastToolTime: 0,
167
+ recentTools: [],
168
+ toolCountSinceLastMessage: 0,
169
+ showToolIndicator: false,
170
+ animationFrame: 0,
171
+ blinkCycle: 0,
172
+ waitingFor: 'none',
173
+ chatBubbleStartTime: 0,
174
+ pendingExit: false,
175
+ crashCount: 0,
176
+ arbiterHasSpoken: false,
177
+ // Requirements overlay state - always start as 'none', overlay triggered after entrance
178
+ requirementsOverlay: 'none',
179
+ requirementsFiles: [],
180
+ requirementsFilteredFiles: [],
181
+ requirementsSearchQuery: '',
182
+ requirementsSelectedIndex: 0,
183
+ requirementsCursorPos: 0,
184
+ requirementsTilesDrawn: false,
185
+ drawingEnabled: true,
186
+ };
187
+ // Tracking for minimal redraws
188
+ const tracker = {
189
+ lastTileFrame: -1,
190
+ lastScrollOffset: -1,
191
+ lastInputBuffer: '',
192
+ lastMode: 'INSERT',
193
+ lastMessageCount: -1,
194
+ lastContextPercent: -1,
195
+ lastOrchestratorPercent: null,
196
+ lastChatWaitingFor: 'none',
197
+ lastChatAnimFrame: -1,
198
+ lastCursorPos: -1,
199
+ lastInputHeight: 1,
200
+ lastShowToolIndicator: false,
201
+ lastToolCount: 0,
202
+ };
203
+ // Callbacks
204
+ let inputCallback = null;
205
+ let exitCallback = null;
206
+ let requirementsReadyCallback = null;
207
+ let animationInterval = null;
208
+ let isRunning = false;
209
+ let inLogViewer = false; // Track when log viewer is open
210
+ let _inRequirementsOverlay = false; // Track when requirements overlay is open
211
+ let entranceComplete = false; // Track if entrance animation is done
212
+ let pendingArbiterMessage = null; // Message waiting for entrance to complete
213
+ let arbiterWalkInterval = null; // For walk animations
214
+ // Track if we need to show requirements prompt after entrance
215
+ const needsRequirementsPrompt = !appState.requirementsPath;
216
+ let summonState = 'idle';
217
+ let pendingDemonSpawns = []; // Queue of demon numbers waiting to appear
218
+ // ============================================================================
219
+ // Drawing Functions (Strategy 5 - Minimal Redraws)
220
+ // ============================================================================
221
+ /**
222
+ * Get or create a cached filler row (grass with occasional trees)
223
+ */
224
+ function getFillerRow(tileset, rowIndex) {
225
+ const cacheKey = `filler-${rowIndex}`;
226
+ if (fillerRowCache.has(cacheKey)) {
227
+ return fillerRowCache.get(cacheKey);
228
+ }
229
+ const grassPixels = extractTile(tileset, TILE.GRASS);
230
+ const rowLines = [];
231
+ // Build one row of tiles (SCENE_WIDTH tiles wide)
232
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
233
+ let line = '';
234
+ for (let col = 0; col < SCENE_WIDTH; col++) {
235
+ // Deterministic pattern for variety: mostly grass, some trees
236
+ const pattern = (rowIndex * 7 + col * 13) % 20;
237
+ let tileIndex;
238
+ if (pattern < 2) {
239
+ tileIndex = TILE.PINE_TREE;
240
+ }
241
+ else if (pattern < 4) {
242
+ tileIndex = TILE.BARE_TREE;
243
+ }
244
+ else if (pattern < 7) {
245
+ tileIndex = TILE.GRASS_SPARSE;
246
+ }
247
+ else {
248
+ tileIndex = TILE.GRASS;
249
+ }
250
+ // Extract and render tile
251
+ let pixels = extractTile(tileset, tileIndex);
252
+ if (tileIndex >= 80) {
253
+ pixels = compositeTiles(pixels, grassPixels, 1);
254
+ }
255
+ const rendered = renderTile(pixels);
256
+ line += rendered[charRow];
257
+ }
258
+ rowLines.push(line);
259
+ }
260
+ fillerRowCache.set(cacheKey, rowLines);
261
+ return rowLines;
262
+ }
263
+ /**
264
+ * Draw tile scene
265
+ * @param force Force redraw even if animation frame unchanged
266
+ */
267
+ function drawTiles(force = false) {
268
+ if (!force && state.animationFrame === tracker.lastTileFrame)
269
+ return;
270
+ tracker.lastTileFrame = state.animationFrame;
271
+ // Skip drawing if log viewer or requirements overlay is open (but state still updates)
272
+ if (inLogViewer || state.requirementsOverlay !== 'none')
273
+ return;
274
+ if (!state.tileset)
275
+ return;
276
+ const layout = getLayout(state.inputBuffer);
277
+ // Draw filler rows above the scene (build from scene upward, so cut-off is at top edge)
278
+ const rowsAbove = layout.tileArea.fillerRowsAbove;
279
+ if (rowsAbove > 0) {
280
+ const fillerTileRowsAbove = Math.ceil(rowsAbove / CHAR_HEIGHT);
281
+ for (let tileRow = 0; tileRow < fillerTileRowsAbove; tileRow++) {
282
+ const fillerLines = getFillerRow(state.tileset, tileRow);
283
+ // Draw from bottom of this filler tile upward
284
+ for (let charRow = CHAR_HEIGHT - 1; charRow >= 0; charRow--) {
285
+ const screenY = layout.tileArea.y - 1 - tileRow * CHAR_HEIGHT - (CHAR_HEIGHT - 1 - charRow);
286
+ if (screenY >= 1) {
287
+ term.moveTo(layout.tileArea.x, screenY);
288
+ process.stdout.write(fillerLines[charRow] + RESET);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ // Create scene from state
294
+ const scene = createScene(state.sceneState);
295
+ // Render scene to ANSI string (activeHops from sceneState, plus chat bubble)
296
+ const sceneStr = renderScene(state.tileset, scene, state.sceneState.activeHops, state.sceneState);
297
+ // Split by lines and write each line
298
+ const lines = sceneStr.split('\n');
299
+ for (let i = 0; i < lines.length; i++) {
300
+ term.moveTo(layout.tileArea.x, layout.tileArea.y + i);
301
+ process.stdout.write(lines[i] + RESET);
302
+ }
303
+ // Draw filler rows below the scene (build from scene downward, so cut-off is at bottom edge)
304
+ const rowsBelow = layout.tileArea.fillerRowsBelow;
305
+ if (rowsBelow > 0) {
306
+ const fillerTileRowsBelow = Math.ceil(rowsBelow / CHAR_HEIGHT);
307
+ for (let tileRow = 0; tileRow < fillerTileRowsBelow; tileRow++) {
308
+ const fillerLines = getFillerRow(state.tileset, tileRow + 100); // Offset for different pattern
309
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
310
+ const screenY = layout.tileArea.y + TILE_AREA_HEIGHT + tileRow * CHAR_HEIGHT + charRow;
311
+ if (screenY <= layout.height) {
312
+ term.moveTo(layout.tileArea.x, screenY);
313
+ process.stdout.write(fillerLines[charRow] + RESET);
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ /**
320
+ * Build the rendered chat lines array - single source of truth for chat content.
321
+ * Includes messages with spacing, working indicator, and tool indicator.
322
+ * Always reserves space for indicators to prevent layout jumping.
323
+ */
324
+ function getRenderedChatLines(chatWidth) {
325
+ const renderedLines = [];
326
+ // Messages with spacing between them
327
+ for (let i = 0; i < state.messages.length; i++) {
328
+ const msg = state.messages[i];
329
+ const prefix = getMessagePrefix(msg);
330
+ const color = getMessageColor(msg.speaker);
331
+ const wrappedLines = wrapText(prefix + msg.text, chatWidth);
332
+ for (const line of wrappedLines) {
333
+ renderedLines.push({ text: line, color });
334
+ }
335
+ // Add blank line between messages (but not after the last one)
336
+ if (i < state.messages.length - 1) {
337
+ renderedLines.push({ text: '', color: COLORS.reset });
338
+ }
339
+ }
340
+ // Working indicator line (always visible when waiting, dots animate)
341
+ if (renderedLines.length > 0) {
342
+ renderedLines.push({ text: '', color: COLORS.reset });
343
+ }
344
+ if (state.waitingFor !== 'none') {
345
+ const waiting = state.waitingFor === 'arbiter' ? 'Arbiter' : 'Conjuring';
346
+ // Animate dots: ., .., ..., blank, repeat (cycles every 4 blink cycles)
347
+ const dotPhase = state.blinkCycle % 4;
348
+ const dots = dotPhase < 3 ? '.'.repeat(dotPhase + 1) : '';
349
+ const indicatorColor = state.waitingFor === 'arbiter' ? COLORS.arbiter : COLORS.orchestrator;
350
+ renderedLines.push({
351
+ text: `${waiting} is working${dots}`,
352
+ color: `\x1b[2m${indicatorColor}`,
353
+ });
354
+ }
355
+ else {
356
+ renderedLines.push({ text: '', color: COLORS.reset });
357
+ }
358
+ // Tool indicator line (directly below working indicator, no extra blank)
359
+ if (state.showToolIndicator && state.recentTools.length > 0) {
360
+ const toolsText = state.recentTools.join(' → ');
361
+ const countText = `(${state.toolCountSinceLastMessage} tool${state.toolCountSinceLastMessage === 1 ? '' : 's'})`;
362
+ const pulse = state.animationFrame < 4 ? '·' : ' ';
363
+ const toolText = `${pulse} ${toolsText} ${countText} ${pulse}`;
364
+ // Use same color as working indicator (yellow for arbiter, cyan for orchestrator)
365
+ const toolColor = state.waitingFor === 'orchestrator' ? COLORS.orchestrator : COLORS.arbiter;
366
+ renderedLines.push({ text: toolText, color: `\x1b[2m${toolColor}` });
367
+ }
368
+ else {
369
+ renderedLines.push({ text: '', color: COLORS.reset });
370
+ }
371
+ return renderedLines;
372
+ }
373
+ /**
374
+ * Draw chat area - only redraws if messages or scroll changed
375
+ */
376
+ function drawChat(force = false) {
377
+ // Skip drawing if log viewer or requirements overlay is open
378
+ if (inLogViewer || state.requirementsOverlay !== 'none')
379
+ return;
380
+ const scrollChanged = state.scrollOffset !== tracker.lastScrollOffset;
381
+ const messagesChanged = state.messages.length !== tracker.lastMessageCount;
382
+ const waitingChanged = state.waitingFor !== tracker.lastChatWaitingFor;
383
+ // Only track blink cycle changes when waiting (for slower blinking effect)
384
+ const blinkChanged = state.waitingFor !== 'none' && state.blinkCycle !== tracker.lastChatAnimFrame;
385
+ // Track tool indicator changes
386
+ const toolIndicatorChanged = state.showToolIndicator !== tracker.lastShowToolIndicator ||
387
+ state.toolCountSinceLastMessage !== tracker.lastToolCount;
388
+ if (!force &&
389
+ !scrollChanged &&
390
+ !messagesChanged &&
391
+ !waitingChanged &&
392
+ !blinkChanged &&
393
+ !toolIndicatorChanged)
394
+ return;
395
+ tracker.lastScrollOffset = state.scrollOffset;
396
+ tracker.lastMessageCount = state.messages.length;
397
+ tracker.lastChatWaitingFor = state.waitingFor;
398
+ tracker.lastShowToolIndicator = state.showToolIndicator;
399
+ tracker.lastToolCount = state.toolCountSinceLastMessage;
400
+ tracker.lastChatAnimFrame = state.blinkCycle;
401
+ const layout = getLayout(state.inputBuffer);
402
+ const visibleLines = layout.chatArea.height;
403
+ // Get rendered lines from single source of truth
404
+ const renderedLines = getRenderedChatLines(layout.chatArea.width);
405
+ // Calculate max scroll
406
+ const maxScroll = Math.max(0, renderedLines.length - visibleLines + SCROLL_PADDING);
407
+ state.scrollOffset = Math.min(Math.max(0, state.scrollOffset), maxScroll);
408
+ // Draw visible lines
409
+ const chatX = layout.chatArea.x;
410
+ const chatWidth = layout.chatArea.width;
411
+ for (let i = 0; i < visibleLines; i++) {
412
+ const y = layout.chatArea.y + i;
413
+ // Clear the line (only the chat area, not tiles)
414
+ term.moveTo(chatX, y);
415
+ process.stdout.write(' '.repeat(chatWidth));
416
+ const lineIdx = state.scrollOffset + i;
417
+ if (lineIdx < renderedLines.length) {
418
+ const { text, color } = renderedLines[lineIdx];
419
+ term.moveTo(chatX, y);
420
+ process.stdout.write(color + text.substring(0, chatWidth) + COLORS.reset);
421
+ }
422
+ }
423
+ }
424
+ /**
425
+ * Draw context bar - shows Arbiter %, Conjuring %, and tool info
426
+ */
427
+ function drawContext(force = false) {
428
+ // Skip drawing if log viewer or requirements overlay is open
429
+ if (inLogViewer || state.requirementsOverlay !== 'none')
430
+ return;
431
+ const contextChanged = state.arbiterContextPercent !== tracker.lastContextPercent ||
432
+ state.orchestratorContextPercent !== tracker.lastOrchestratorPercent;
433
+ if (!force && !contextChanged)
434
+ return;
435
+ // Update trackers
436
+ tracker.lastContextPercent = state.arbiterContextPercent;
437
+ tracker.lastOrchestratorPercent = state.orchestratorContextPercent;
438
+ const layout = getLayout(state.inputBuffer);
439
+ const contextX = layout.contextBar.x;
440
+ const contextY = layout.contextBar.y;
441
+ // Clear the context line (only chat area width, not the tile scene)
442
+ term.moveTo(contextX, contextY);
443
+ process.stdout.write(' '.repeat(layout.contextBar.width));
444
+ // Build context info
445
+ let contextInfo = '';
446
+ // Arbiter context
447
+ const arbiterCtx = `Arbiter: ${state.arbiterContextPercent.toFixed(1)}%`;
448
+ contextInfo += `\x1b[33m${arbiterCtx}\x1b[0m`;
449
+ // Orchestrator context
450
+ if (state.orchestratorContextPercent !== null) {
451
+ const orchCtx = ` · Conjuring: ${state.orchestratorContextPercent.toFixed(1)}%`;
452
+ contextInfo += `\x1b[36m${orchCtx}\x1b[0m`;
453
+ }
454
+ // Tool info removed - now shown in chat area indicator instead
455
+ // Crash indicator
456
+ if (state.crashCount > 0) {
457
+ contextInfo += ` · \x1b[31m⚠ ${state.crashCount} crash${state.crashCount > 1 ? 'es' : ''}\x1b[0m`;
458
+ }
459
+ term.moveTo(contextX, contextY);
460
+ process.stdout.write(contextInfo);
461
+ }
462
+ /**
463
+ * Draw status bar - only redraws if mode changed
464
+ * Shows mode indicator and keyboard hints only (context info is on separate line above)
465
+ */
466
+ function drawStatus(force = false) {
467
+ // Skip drawing if log viewer or requirements overlay is open
468
+ if (inLogViewer || state.requirementsOverlay !== 'none')
469
+ return;
470
+ const modeChanged = state.mode !== tracker.lastMode;
471
+ if (!force && !modeChanged)
472
+ return;
473
+ const layout = getLayout(state.inputBuffer);
474
+ const statusX = layout.statusBar.x;
475
+ const statusY = layout.statusBar.y;
476
+ // Clear the status line (only chat area width, not the tile scene)
477
+ term.moveTo(statusX, statusY);
478
+ process.stdout.write(' '.repeat(layout.statusBar.width));
479
+ // Exit confirmation mode - show special prompt
480
+ if (state.pendingExit) {
481
+ const arbiterSid = appState.arbiterSessionId || '(none)';
482
+ const orchSid = appState.currentOrchestrator?.sessionId;
483
+ let exitPrompt = `\x1b[41;97m EXIT? \x1b[0m \x1b[1mPress y to quit, any other key to cancel\x1b[0m`;
484
+ exitPrompt += ` \x1b[2mArbiter: ${arbiterSid}`;
485
+ if (orchSid) {
486
+ exitPrompt += ` | Orch: ${orchSid}`;
487
+ }
488
+ exitPrompt += '\x1b[0m';
489
+ term.moveTo(statusX, statusY);
490
+ process.stdout.write(exitPrompt);
491
+ return;
492
+ }
493
+ // Build status line (mode + hints only - context info is now on separate line)
494
+ let statusLine = '';
495
+ if (state.mode === 'INSERT') {
496
+ statusLine += '\x1b[42;30m INSERT \x1b[0m'; // Green bg, black text
497
+ statusLine +=
498
+ '\x1b[38;2;140;140;140m esc:scroll · \\+enter:newline · ^C:quit · ^Z:suspend \x1b[0m';
499
+ }
500
+ else {
501
+ statusLine += '\x1b[48;2;130;44;19m\x1b[97m SCROLL \x1b[0m'; // Brown bg (130,44,19), bright white text
502
+ statusLine +=
503
+ '\x1b[38;2;140;140;140m i:insert · ↑/↓:scroll · o:log · ^C:quit · ^Z:suspend \x1b[0m';
504
+ }
505
+ term.moveTo(statusX, statusY);
506
+ process.stdout.write(statusLine);
507
+ }
508
+ /**
509
+ * Draw input area - only redraws if input changed
510
+ * Now supports multi-line input (1-5 lines based on content)
511
+ */
512
+ function drawInput(force = false) {
513
+ const layout = getLayout(state.inputBuffer);
514
+ const inputHeight = layout.inputArea.height;
515
+ const inputChanged = state.inputBuffer !== tracker.lastInputBuffer ||
516
+ state.mode !== tracker.lastMode ||
517
+ state.cursorPos !== tracker.lastCursorPos;
518
+ const heightChanged = inputHeight !== tracker.lastInputHeight;
519
+ if (!force && !inputChanged && !heightChanged)
520
+ return;
521
+ // Handle input height changes - need to clear old areas and redraw context/status
522
+ if (heightChanged) {
523
+ // Calculate where the OLD context bar was (before height change)
524
+ const oldInputHeight = tracker.lastInputHeight;
525
+ const oldInputY = layout.height - oldInputHeight + 1;
526
+ const oldStatusY = oldInputY - 1;
527
+ const oldContextY = oldStatusY - 1;
528
+ // New context position
529
+ const newContextY = layout.contextBar.y;
530
+ // Clear from the higher of old/new context positions down to bottom
531
+ // This ensures ghost lines are cleared when input shrinks
532
+ const clearStartY = Math.min(oldContextY, newContextY);
533
+ for (let y = clearStartY; y <= layout.height; y++) {
534
+ if (y >= 1) {
535
+ term.moveTo(layout.inputArea.x, y);
536
+ // Only clear chat area width, not the tile scene on the left
537
+ process.stdout.write(' '.repeat(layout.inputArea.width));
538
+ }
539
+ }
540
+ tracker.lastInputHeight = inputHeight;
541
+ // Redraw context and status at their new positions
542
+ drawContext(true);
543
+ drawStatus(true);
544
+ }
545
+ tracker.lastInputBuffer = state.inputBuffer;
546
+ tracker.lastMode = state.mode;
547
+ tracker.lastCursorPos = state.cursorPos;
548
+ const inputX = layout.inputArea.x;
549
+ const inputY = layout.inputArea.y;
550
+ const inputWidth = layout.inputArea.width;
551
+ // Clear all input lines
552
+ for (let i = 0; i < inputHeight; i++) {
553
+ term.moveTo(inputX, inputY + i);
554
+ process.stdout.write(' '.repeat(inputWidth));
555
+ }
556
+ // Wrap input text for multi-line display
557
+ const promptWidth = 2; // "> " or ": "
558
+ const textWidth = inputWidth - promptWidth - 1; // -1 for cursor space
559
+ const wrappedLines = wrapInputText(state.inputBuffer, textWidth);
560
+ // Calculate cursor position in wrapped lines
561
+ // Need to map state.cursorPos (char index in inputBuffer) to (line, col) in wrappedLines
562
+ let cursorLine = 0;
563
+ let cursorCol = 0;
564
+ if (state.inputBuffer.length > 0) {
565
+ // Walk through the input buffer to find which wrapped line the cursor is on
566
+ const paragraphs = state.inputBuffer.split('\n');
567
+ let charIndex = 0;
568
+ let lineIndex = 0;
569
+ let found = false;
570
+ outer: for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) {
571
+ const para = paragraphs[pIdx];
572
+ if (para.length === 0) {
573
+ // Empty paragraph (from newline)
574
+ if (state.cursorPos === charIndex) {
575
+ cursorLine = lineIndex;
576
+ cursorCol = 0;
577
+ found = true;
578
+ break;
579
+ }
580
+ lineIndex++;
581
+ }
582
+ else {
583
+ // Wrap the paragraph
584
+ for (let i = 0; i < para.length; i += textWidth) {
585
+ const lineEnd = Math.min(i + textWidth, para.length);
586
+ // Check if cursor is in this line segment
587
+ if (state.cursorPos >= charIndex + i && state.cursorPos < charIndex + lineEnd) {
588
+ cursorLine = lineIndex;
589
+ cursorCol = state.cursorPos - charIndex - i;
590
+ found = true;
591
+ break outer;
592
+ }
593
+ // Check if cursor is exactly at the end of this line segment (but before next line)
594
+ if (state.cursorPos === charIndex + lineEnd && lineEnd === para.length) {
595
+ // Cursor at end of paragraph - show at end of this line
596
+ cursorLine = lineIndex;
597
+ cursorCol = lineEnd - i;
598
+ found = true;
599
+ break outer;
600
+ }
601
+ lineIndex++;
602
+ }
603
+ charIndex += para.length;
604
+ }
605
+ // Add 1 for newline between paragraphs (except after last)
606
+ if (pIdx < paragraphs.length - 1) {
607
+ if (state.cursorPos === charIndex) {
608
+ // Cursor is right at the newline - show at start of next line
609
+ cursorLine = lineIndex;
610
+ cursorCol = 0;
611
+ found = true;
612
+ break;
613
+ }
614
+ charIndex++; // For the \n
615
+ }
616
+ }
617
+ // Handle cursor at very end of input
618
+ if (!found || state.cursorPos >= state.inputBuffer.length) {
619
+ cursorLine = wrappedLines.length - 1;
620
+ cursorCol = wrappedLines[cursorLine]?.length || 0;
621
+ }
622
+ }
623
+ // Adjust scroll to keep cursor visible
624
+ let adjustedStartLine = Math.max(0, wrappedLines.length - inputHeight);
625
+ if (cursorLine < adjustedStartLine) {
626
+ adjustedStartLine = cursorLine;
627
+ }
628
+ else if (cursorLine >= adjustedStartLine + inputHeight) {
629
+ adjustedStartLine = cursorLine - inputHeight + 1;
630
+ }
631
+ // Get final visible lines after scroll adjustment
632
+ const visibleLines = wrappedLines.slice(adjustedStartLine, adjustedStartLine + inputHeight);
633
+ // Draw each line
634
+ for (let i = 0; i < visibleLines.length; i++) {
635
+ term.moveTo(inputX, inputY + i);
636
+ if (i === 0 && adjustedStartLine === 0) {
637
+ // First line gets the prompt
638
+ if (state.mode === 'INSERT') {
639
+ term.cyan('> ');
640
+ }
641
+ else {
642
+ term.blue(': ');
643
+ }
644
+ }
645
+ else {
646
+ // Continuation lines get indent to align with text
647
+ process.stdout.write(' ');
648
+ }
649
+ term.white(visibleLines[i]);
650
+ }
651
+ // Draw cursor at calculated position (only in INSERT mode)
652
+ if (state.mode === 'INSERT') {
653
+ const visibleCursorLine = cursorLine - adjustedStartLine;
654
+ if (visibleCursorLine >= 0 && visibleCursorLine < inputHeight) {
655
+ term.moveTo(inputX + promptWidth + cursorCol, inputY + visibleCursorLine);
656
+ term.bgWhite.black(' ');
657
+ }
658
+ }
659
+ }
660
+ /**
661
+ * Wrap input text for multi-line display
662
+ */
663
+ function wrapInputText(text, width) {
664
+ if (!text || width <= 0)
665
+ return [''];
666
+ const lines = [];
667
+ const paragraphs = text.split('\n');
668
+ for (const para of paragraphs) {
669
+ if (para.length === 0) {
670
+ lines.push('');
671
+ }
672
+ else {
673
+ // Break paragraph into lines of 'width' characters
674
+ for (let i = 0; i < para.length; i += width) {
675
+ lines.push(para.substring(i, i + width));
676
+ }
677
+ }
678
+ }
679
+ return lines.length > 0 ? lines : [''];
680
+ }
681
+ // Debounce tracking for fullDraw (prevents signal storm on dtach reattach)
682
+ let lastFullDrawTime = 0;
683
+ const FULL_DRAW_DEBOUNCE_MS = 50;
684
+ /**
685
+ * Full redraw of all components
686
+ */
687
+ function fullDraw() {
688
+ // Skip all drawing when disabled (suspended/detached)
689
+ if (!state.drawingEnabled)
690
+ return;
691
+ // Skip drawing if no TTY (dtach detached)
692
+ if (!process.stdout.isTTY)
693
+ return;
694
+ // Debounce rapid fullDraw calls (signal storm on dtach reattach)
695
+ const now = Date.now();
696
+ if (now - lastFullDrawTime < FULL_DRAW_DEBOUNCE_MS)
697
+ return;
698
+ lastFullDrawTime = now;
699
+ // Reset trackers to force redraw
700
+ tracker.lastTileFrame = -1;
701
+ tracker.lastScrollOffset = -1;
702
+ tracker.lastInputBuffer = '';
703
+ tracker.lastMode = state.mode;
704
+ tracker.lastMessageCount = -1;
705
+ tracker.lastContextPercent = -1;
706
+ tracker.lastOrchestratorPercent = null;
707
+ tracker.lastInputHeight = 1;
708
+ term.clear();
709
+ // Draw requirements overlay if active, otherwise normal UI
710
+ if (state.requirementsOverlay !== 'none') {
711
+ drawRequirementsOverlay();
712
+ }
713
+ else {
714
+ drawTiles(true);
715
+ drawChat(true);
716
+ drawContext(true);
717
+ drawStatus(true);
718
+ drawInput(true);
719
+ }
720
+ }
721
+ // ============================================================================
722
+ // Requirements Overlay Functions
723
+ // ============================================================================
724
+ /**
725
+ * Draw the requirements overlay based on current state
726
+ */
727
+ function drawRequirementsOverlay() {
728
+ if (!state.tileset)
729
+ return;
730
+ // Draw the scene (left side) only if not already drawn - avoids flicker on keystroke
731
+ if (!state.requirementsTilesDrawn) {
732
+ drawTilesInternal();
733
+ state.requirementsTilesDrawn = true;
734
+ }
735
+ let panelLines = [];
736
+ let panelWidth = 6; // tiles
737
+ switch (state.requirementsOverlay) {
738
+ case 'prompt':
739
+ panelLines = renderMessagePanel(state.tileset, [
740
+ '\x1b[97mDo you bring a Scroll of Requirements?\x1b[0m',
741
+ '\x1b[90m(a detailed markdown file describing your task)\x1b[0m',
742
+ '',
743
+ '\x1b[90mThe Arbiter rewards those who come prepared.\x1b[0m',
744
+ '\x1b[90mScrolls contain context, specs, and acceptance criteria.\x1b[0m',
745
+ '',
746
+ "\x1b[92m[Y]\x1b[0m Yes, I have a .md file \x1b[91m[N]\x1b[0m No, I'll wing it",
747
+ ], panelWidth);
748
+ break;
749
+ case 'picker': {
750
+ // Build file list display with cursor at correct position
751
+ const beforeCursor = state.requirementsSearchQuery.slice(0, state.requirementsCursorPos);
752
+ const afterCursor = state.requirementsSearchQuery.slice(state.requirementsCursorPos);
753
+ const inputLineWithCursor = `\x1b[90m> ${beforeCursor}\x1b[97m_\x1b[90m${afterCursor}\x1b[0m`;
754
+ const displayLines = [
755
+ '\x1b[97mSelect your Scroll of Requirements:\x1b[0m',
756
+ '\x1b[90m(markdown files in your project)\x1b[0m',
757
+ '',
758
+ inputLineWithCursor,
759
+ '',
760
+ ];
761
+ // Show filtered files (max 16 for 3-tile tall panel)
762
+ const files = state.requirementsFilteredFiles.length > 0
763
+ ? state.requirementsFilteredFiles
764
+ : state.requirementsFiles;
765
+ const maxVisible = 16;
766
+ const startIdx = Math.max(0, state.requirementsSelectedIndex - Math.floor(maxVisible / 2));
767
+ const visibleFiles = files.slice(startIdx, startIdx + maxVisible);
768
+ visibleFiles.forEach((file, i) => {
769
+ const actualIdx = startIdx + i;
770
+ const isSelected = actualIdx === state.requirementsSelectedIndex;
771
+ const prefix = isSelected ? '\x1b[93m> ' : ' ';
772
+ const suffix = isSelected ? '\x1b[0m' : '';
773
+ displayLines.push(`${prefix}${file}${suffix}`);
774
+ });
775
+ if (files.length === 0) {
776
+ displayLines.push('\x1b[90m(no .md files found - the scroll rack is empty)\x1b[0m');
777
+ }
778
+ displayLines.push('');
779
+ displayLines.push('\x1b[90mArrow keys navigate Enter select Esc to flee\x1b[0m');
780
+ panelWidth = 7;
781
+ panelLines = renderMessagePanel(state.tileset, displayLines, panelWidth, 3);
782
+ break;
783
+ }
784
+ case 'rat-transform':
785
+ panelLines = renderMessagePanel(state.tileset, [
786
+ '\x1b[91mYou have been transformed into a rat.\x1b[0m',
787
+ '\x1b[90m(the Arbiter does not suffer the unprepared)\x1b[0m',
788
+ '',
789
+ '\x1b[90mCome back with a requirements.md or similar.\x1b[0m',
790
+ '',
791
+ '\x1b[90mPress any key to scurry away...\x1b[0m',
792
+ ], 5);
793
+ break;
794
+ default:
795
+ return;
796
+ }
797
+ // Position the panel within the SCENE area only (not whole screen)
798
+ // Scene dimensions: 7 tiles x 6 tiles = 112 chars x 48 rows
799
+ const sceneWidthChars = 7 * 16; // 112
800
+ const sceneHeightChars = 6 * 8; // 48
801
+ const panelWidthChars = panelWidth * 16;
802
+ const panelHeight = panelLines.length;
803
+ // Center panel within scene area
804
+ const panelX = Math.max(1, Math.floor((sceneWidthChars - panelWidthChars) / 2));
805
+ // For rat-transform, position at bottom of scene, shifted one tile right; for others, center vertically in scene
806
+ let panelY;
807
+ let finalPanelX = panelX;
808
+ if (state.requirementsOverlay === 'rat-transform') {
809
+ panelY = sceneHeightChars - panelHeight + 4; // Lower position so arbiter at row 3 is visible
810
+ finalPanelX = panelX + 16; // One tile to the right
811
+ }
812
+ else {
813
+ panelY = Math.max(1, Math.floor((sceneHeightChars - panelHeight) / 2));
814
+ }
815
+ // Draw panel
816
+ for (let i = 0; i < panelLines.length; i++) {
817
+ term.moveTo(finalPanelX, panelY + i);
818
+ process.stdout.write(panelLines[i] + RESET);
819
+ }
820
+ }
821
+ /**
822
+ * Redraw just the picker input line (for cursor movement)
823
+ * Falls back to full panel redraw for simplicity, but structured for future optimization
824
+ */
825
+ function drawPickerInput() {
826
+ // For now, redraw the full panel (but not the tiles)
827
+ drawRequirementsOverlay();
828
+ }
829
+ /**
830
+ * Redraw the picker file list only
831
+ * Falls back to full panel redraw for simplicity, but structured for future optimization
832
+ */
833
+ function drawPickerList() {
834
+ // For now, redraw the full panel (but not the tiles)
835
+ drawRequirementsOverlay();
836
+ }
837
+ /**
838
+ * Redraw picker content (input + list) without full panel redraw
839
+ * Falls back to full panel redraw for simplicity, but structured for future optimization
840
+ */
841
+ function drawPickerContent() {
842
+ // For now, redraw the full panel (but not the tiles)
843
+ drawRequirementsOverlay();
844
+ }
845
+ /**
846
+ * Internal tile drawing (used by overlay to draw scene behind)
847
+ * This is a simplified version that doesn't check overlay state
848
+ */
849
+ function drawTilesInternal() {
850
+ if (!state.tileset)
851
+ return;
852
+ const layout = getLayout(state.inputBuffer);
853
+ // Draw filler rows above the scene
854
+ const rowsAbove = layout.tileArea.fillerRowsAbove;
855
+ if (rowsAbove > 0) {
856
+ const fillerTileRowsAbove = Math.ceil(rowsAbove / CHAR_HEIGHT);
857
+ for (let tileRow = 0; tileRow < fillerTileRowsAbove; tileRow++) {
858
+ const fillerLines = getFillerRow(state.tileset, tileRow);
859
+ for (let charRow = CHAR_HEIGHT - 1; charRow >= 0; charRow--) {
860
+ const screenY = layout.tileArea.y - 1 - tileRow * CHAR_HEIGHT - (CHAR_HEIGHT - 1 - charRow);
861
+ if (screenY >= 1) {
862
+ term.moveTo(layout.tileArea.x, screenY);
863
+ process.stdout.write(fillerLines[charRow] + RESET);
864
+ }
865
+ }
866
+ }
867
+ }
868
+ // Create scene from state
869
+ const scene = createScene(state.sceneState);
870
+ // Render scene to ANSI string
871
+ const sceneStr = renderScene(state.tileset, scene, state.sceneState.activeHops, state.sceneState);
872
+ // Split by lines and write each line
873
+ const lines = sceneStr.split('\n');
874
+ for (let i = 0; i < lines.length; i++) {
875
+ term.moveTo(layout.tileArea.x, layout.tileArea.y + i);
876
+ process.stdout.write(lines[i] + RESET);
877
+ }
878
+ // Draw filler rows below the scene
879
+ const rowsBelow = layout.tileArea.fillerRowsBelow;
880
+ if (rowsBelow > 0) {
881
+ const fillerTileRowsBelow = Math.ceil(rowsBelow / CHAR_HEIGHT);
882
+ for (let tileRow = 0; tileRow < fillerTileRowsBelow; tileRow++) {
883
+ const fillerLines = getFillerRow(state.tileset, tileRow + 100);
884
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
885
+ const screenY = layout.tileArea.y + TILE_AREA_HEIGHT + tileRow * CHAR_HEIGHT + charRow;
886
+ if (screenY <= layout.height) {
887
+ term.moveTo(layout.tileArea.x, screenY);
888
+ process.stdout.write(fillerLines[charRow] + RESET);
889
+ }
890
+ }
891
+ }
892
+ }
893
+ }
894
+ /**
895
+ * Key handler for requirements overlay
896
+ */
897
+ function requirementsKeyHandler(key) {
898
+ switch (state.requirementsOverlay) {
899
+ case 'prompt':
900
+ if (key === 'y' || key === 'Y') {
901
+ // Switch to file picker
902
+ loadFilesForPicker();
903
+ state.requirementsOverlay = 'picker';
904
+ _inRequirementsOverlay = true;
905
+ drawRequirementsOverlay();
906
+ }
907
+ else if (key === 'n' || key === 'N') {
908
+ // Rat transformation
909
+ playSfx('magic');
910
+ // First show smoke
911
+ state.sceneState.selectedCharacter = TILE.SMOKE; // 90
912
+ state.requirementsTilesDrawn = false; // Reset so tiles get redrawn with smoke
913
+ drawTilesInternal();
914
+ state.requirementsTilesDrawn = true; // Mark as drawn
915
+ // After 1500ms (3x longer smoke animation), show rat
916
+ setTimeout(() => {
917
+ state.sceneState.selectedCharacter = 210; // Rat tile
918
+ state.requirementsOverlay = 'rat-transform';
919
+ state.requirementsTilesDrawn = false; // Reset so tiles get redrawn with rat
920
+ drawRequirementsOverlay();
921
+ }, 1500);
922
+ }
923
+ break;
924
+ case 'picker':
925
+ if (key === 'UP') {
926
+ state.requirementsSelectedIndex = Math.max(0, state.requirementsSelectedIndex - 1);
927
+ drawPickerList();
928
+ }
929
+ else if (key === 'DOWN') {
930
+ const files = state.requirementsFilteredFiles.length > 0
931
+ ? state.requirementsFilteredFiles
932
+ : state.requirementsFiles;
933
+ state.requirementsSelectedIndex = Math.min(files.length - 1, state.requirementsSelectedIndex + 1);
934
+ drawPickerList();
935
+ }
936
+ else if (key === 'LEFT') {
937
+ // Move cursor left in search query
938
+ state.requirementsCursorPos = Math.max(0, state.requirementsCursorPos - 1);
939
+ drawPickerInput();
940
+ }
941
+ else if (key === 'RIGHT') {
942
+ // Move cursor right in search query
943
+ state.requirementsCursorPos = Math.min(state.requirementsSearchQuery.length, state.requirementsCursorPos + 1);
944
+ drawPickerInput();
945
+ }
946
+ else if (key === 'ENTER') {
947
+ // Select file
948
+ const files = state.requirementsFilteredFiles.length > 0
949
+ ? state.requirementsFilteredFiles
950
+ : state.requirementsFiles;
951
+ if (files.length > 0) {
952
+ const selectedFile = files[state.requirementsSelectedIndex];
953
+ selectRequirementsFile(selectedFile);
954
+ }
955
+ }
956
+ else if (key === 'ESCAPE') {
957
+ // Cancel - go back to prompt
958
+ state.requirementsOverlay = 'prompt';
959
+ state.requirementsSearchQuery = '';
960
+ state.requirementsSelectedIndex = 0;
961
+ state.requirementsCursorPos = 0;
962
+ state.requirementsTilesDrawn = false; // Force scene redraw to clear larger picker
963
+ drawRequirementsOverlay();
964
+ }
965
+ else if (key === 'BACKSPACE') {
966
+ // Delete char before cursor
967
+ if (state.requirementsCursorPos > 0) {
968
+ state.requirementsSearchQuery =
969
+ state.requirementsSearchQuery.slice(0, state.requirementsCursorPos - 1) +
970
+ state.requirementsSearchQuery.slice(state.requirementsCursorPos);
971
+ state.requirementsCursorPos--;
972
+ filterFiles();
973
+ drawPickerContent();
974
+ }
975
+ }
976
+ else if (key.length === 1 && key.match(/[a-zA-Z0-9._\-/]/)) {
977
+ // Insert char at cursor position
978
+ state.requirementsSearchQuery =
979
+ state.requirementsSearchQuery.slice(0, state.requirementsCursorPos) +
980
+ key +
981
+ state.requirementsSearchQuery.slice(state.requirementsCursorPos);
982
+ state.requirementsCursorPos++;
983
+ filterFiles();
984
+ drawPickerContent();
985
+ }
986
+ break;
987
+ case 'rat-transform':
988
+ // Any key exits
989
+ process.exit(0);
990
+ break;
991
+ }
992
+ }
993
+ /**
994
+ * Load files for the picker using recursive directory walk
995
+ */
996
+ function loadFilesForPicker() {
997
+ const files = [];
998
+ function walkDir(dir, prefix = '') {
999
+ try {
1000
+ const entries = fs.readdirSync(dir);
1001
+ for (const entry of entries) {
1002
+ if (entry.startsWith('.') || entry === 'node_modules')
1003
+ continue;
1004
+ const fullPath = path.join(dir, entry);
1005
+ const relativePath = prefix ? `${prefix}/${entry}` : entry;
1006
+ try {
1007
+ const stat = fs.statSync(fullPath);
1008
+ if (stat.isDirectory()) {
1009
+ walkDir(fullPath, relativePath);
1010
+ }
1011
+ else if (entry.endsWith('.md')) {
1012
+ files.push(relativePath);
1013
+ }
1014
+ }
1015
+ catch {
1016
+ // Skip files we can't stat
1017
+ }
1018
+ }
1019
+ }
1020
+ catch {
1021
+ // Skip directories we can't read
1022
+ }
1023
+ }
1024
+ walkDir(process.cwd());
1025
+ state.requirementsFiles = files;
1026
+ state.requirementsFilteredFiles = files;
1027
+ }
1028
+ /**
1029
+ * Filter files using fzf algorithm
1030
+ */
1031
+ function filterFiles() {
1032
+ if (!state.requirementsSearchQuery) {
1033
+ state.requirementsFilteredFiles = state.requirementsFiles;
1034
+ state.requirementsSelectedIndex = 0;
1035
+ return;
1036
+ }
1037
+ const fzf = new Fzf(state.requirementsFiles);
1038
+ const results = fzf.find(state.requirementsSearchQuery);
1039
+ state.requirementsFilteredFiles = results.map((r) => r.item);
1040
+ state.requirementsSelectedIndex = 0;
1041
+ }
1042
+ /**
1043
+ * Select a requirements file and close overlay
1044
+ */
1045
+ function selectRequirementsFile(filePath) {
1046
+ appState.requirementsPath = filePath;
1047
+ state.sceneState.scrollVisible = true;
1048
+ state.requirementsOverlay = 'none';
1049
+ state.requirementsTilesDrawn = false; // Reset for next time overlay is shown
1050
+ _inRequirementsOverlay = false;
1051
+ fullDraw();
1052
+ // Signal that requirements are ready (router can start now)
1053
+ if (requirementsReadyCallback) {
1054
+ requirementsReadyCallback();
1055
+ requirementsReadyCallback = null;
1056
+ }
1057
+ // NOW trigger arbiter walk (was waiting for requirements selection)
1058
+ startArbiterWalk();
1059
+ }
1060
+ // ============================================================================
1061
+ // Helper Functions
1062
+ // ============================================================================
1063
+ /**
1064
+ * Strip ANSI escape codes from a string to get visible length
1065
+ */
1066
+ function stripAnsi(str) {
1067
+ // eslint-disable-next-line no-control-regex
1068
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
1069
+ }
1070
+ /**
1071
+ * Create middle fill row for dialogue box (samples from left tile's middle column)
1072
+ */
1073
+ function createMiddleFill(leftTile, charRow) {
1074
+ const pixelRowTop = charRow * 2;
1075
+ const pixelRowBot = pixelRowTop + 1;
1076
+ const sampleX = 8; // Middle column
1077
+ let result = '';
1078
+ for (let x = 0; x < 16; x++) {
1079
+ const topPixel = leftTile[pixelRowTop][sampleX];
1080
+ const botPixel = leftTile[pixelRowBot]?.[sampleX] || topPixel;
1081
+ result += `\x1b[48;2;${topPixel.r};${topPixel.g};${topPixel.b}m`;
1082
+ result += `\x1b[38;2;${botPixel.r};${botPixel.g};${botPixel.b}m`;
1083
+ result += '\u2584';
1084
+ }
1085
+ result += RESET;
1086
+ return result;
1087
+ }
1088
+ /**
1089
+ * Wrap text with consistent background color
1090
+ */
1091
+ function wrapTextWithBg(text, bgColor) {
1092
+ const bgMaintained = text.replace(/\x1b\[0m/g, `\x1b[0m${bgColor}`);
1093
+ return bgColor + bgMaintained + RESET;
1094
+ }
1095
+ /**
1096
+ * Create middle row border segments for panels taller than 2 tiles.
1097
+ *
1098
+ * For panels with heightTiles > 2, we need "middle" tile rows that only have
1099
+ * left and right vertical borders (no top/bottom horizontal borders). This function
1100
+ * generates the ANSI strings for those vertical borders by sampling from the
1101
+ * BOTTOM HALF of the top corner tiles (38, 39), which contain just the vertical
1102
+ * border lines without the horizontal top border.
1103
+ *
1104
+ * The pattern from the bottom half of the tiles is repeated for all 8 character
1105
+ * rows of each middle tile row using modulo 4 (since bottom half = 4 char rows).
1106
+ *
1107
+ * @param tileset - The loaded tileset containing dialogue corner tiles
1108
+ * @param charRow - Which character row within the tile (0-7)
1109
+ * @returns Object containing:
1110
+ * - left: 16-char wide left border segment (ANSI string)
1111
+ * - fill: 16-char wide interior fill segment (solid background color)
1112
+ * - right: 16-char wide right border segment (ANSI string)
1113
+ *
1114
+ * @internal Used by renderMessagePanel() for heightTiles > 2
1115
+ */
1116
+ function createMiddleRowBorders(tileset, charRow) {
1117
+ // Use the BOTTOM HALF of the top corner tiles for middle rows
1118
+ // This gives us just the vertical border without horizontal border lines
1119
+ const topLeftTile = extractTile(tileset, DIALOGUE_TILES.TOP_LEFT);
1120
+ const topRightTile = extractTile(tileset, DIALOGUE_TILES.TOP_RIGHT);
1121
+ // For middle rows, we render from the bottom half of the top tiles
1122
+ // Bottom half of tile is pixel rows 8-15 (4 char rows worth)
1123
+ // Use modulo to repeat this pattern for all 8 char rows
1124
+ const actualCharRow = charRow % 4;
1125
+ const pixelRowTop = 8 + actualCharRow * 2; // Start from row 8 (bottom half)
1126
+ const pixelRowBot = pixelRowTop + 1;
1127
+ // Render left border (full 16 chars from bottom half of top-left tile)
1128
+ let left = '';
1129
+ for (let x = 0; x < 16; x++) {
1130
+ const topPixel = topLeftTile[pixelRowTop][x];
1131
+ const botPixel = topLeftTile[pixelRowBot]?.[x] || topPixel;
1132
+ left += `\x1b[48;2;${topPixel.r};${topPixel.g};${topPixel.b}m`;
1133
+ left += `\x1b[38;2;${botPixel.r};${botPixel.g};${botPixel.b}m`;
1134
+ left += '\u2584';
1135
+ }
1136
+ left += RESET;
1137
+ // Render right border (full 16 chars from bottom half of top-right tile)
1138
+ let right = '';
1139
+ for (let x = 0; x < 16; x++) {
1140
+ const topPixel = topRightTile[pixelRowTop][x];
1141
+ const botPixel = topRightTile[pixelRowBot]?.[x] || topPixel;
1142
+ right += `\x1b[48;2;${topPixel.r};${topPixel.g};${topPixel.b}m`;
1143
+ right += `\x1b[38;2;${botPixel.r};${botPixel.g};${botPixel.b}m`;
1144
+ right += '\u2584';
1145
+ }
1146
+ right += RESET;
1147
+ // For fill, sample the interior color from center of tile
1148
+ const sampleX = 8;
1149
+ const topPixel = topLeftTile[pixelRowTop][sampleX];
1150
+ const botPixel = topLeftTile[pixelRowBot]?.[sampleX] || topPixel;
1151
+ let fill = '';
1152
+ for (let x = 0; x < 16; x++) {
1153
+ fill += `\x1b[48;2;${topPixel.r};${topPixel.g};${topPixel.b}m`;
1154
+ fill += `\x1b[38;2;${botPixel.r};${botPixel.g};${botPixel.b}m`;
1155
+ fill += '\u2584';
1156
+ }
1157
+ fill += RESET;
1158
+ return { left, fill, right };
1159
+ }
1160
+ /**
1161
+ * Render a tile-bordered message panel with customizable dimensions.
1162
+ *
1163
+ * Creates a decorative RPG-style panel using dialogue box corner tiles from the
1164
+ * Jerom Fantasy Tileset. The panel consists of:
1165
+ * - Corner tiles (38=top-left, 39=top-right, 48=bottom-left, 49=bottom-right)
1166
+ * - Horizontal fill segments between corners (sampled from corner tile interiors)
1167
+ * - Vertical border segments for middle rows (panels taller than 2 tiles)
1168
+ *
1169
+ * Text is automatically centered both horizontally and vertically within the
1170
+ * interior area. ANSI color codes in textLines are preserved and handled correctly.
1171
+ * Long lines are truncated with "..." if they exceed the interior width.
1172
+ *
1173
+ * ## Panel Structure
1174
+ *
1175
+ * For a 5-wide x 3-tall panel:
1176
+ * ```
1177
+ * [TL][fill][fill][fill][TR] <- Top row of tiles (8 char rows)
1178
+ * [L ][ interior ][R ] <- Middle row(s) (8 char rows each)
1179
+ * [BL][fill][fill][fill][BR] <- Bottom row of tiles (8 char rows)
1180
+ * ```
1181
+ *
1182
+ * ## Dimensions Reference
1183
+ *
1184
+ * - Each tile = 16 characters wide x 8 character rows tall
1185
+ * - Interior width = (widthTiles - 2) * 16 characters
1186
+ * - Total height = heightTiles * 8 character rows
1187
+ * - Usable text area excludes ~2 rows at top and bottom for borders
1188
+ *
1189
+ * @param tileset - The loaded tileset containing dialogue tiles (38, 39, 48, 49)
1190
+ * @param textLines - Array of text lines to display. Can include ANSI color codes.
1191
+ * Lines exceeding interior width are truncated with "..."
1192
+ * @param widthTiles - Panel width in tiles (default 5). Minimum 2 (corners only).
1193
+ * Each tile adds 16 characters of width.
1194
+ * - 4 tiles = 64 chars (48 interior)
1195
+ * - 5 tiles = 80 chars (48 interior)
1196
+ * - 6 tiles = 96 chars (64 interior)
1197
+ * @param heightTiles - Panel height in tiles (default 2). Minimum 2 (top + bottom row).
1198
+ * Each tile adds 8 character rows.
1199
+ * - 2 tiles = 16 rows (~12 usable for text)
1200
+ * - 3 tiles = 24 rows (~20 usable for text)
1201
+ * - 4 tiles = 32 rows (~28 usable for text)
1202
+ * @returns Array of ANSI strings, one per character row (length = heightTiles * 8)
1203
+ *
1204
+ * @example
1205
+ * // Small 2-tile high prompt (16 char rows, fits ~3-4 lines of text)
1206
+ * const panel = renderMessagePanel(tileset, ['Hello!', 'Press any key...'], 4, 2);
1207
+ *
1208
+ * @example
1209
+ * // Taller 3-tile panel for file picker or multi-line content (24 char rows)
1210
+ * const fileList = ['config.json', 'index.ts', 'package.json', 'README.md'];
1211
+ * const panel = renderMessagePanel(tileset, fileList, 6, 3);
1212
+ *
1213
+ * @example
1214
+ * // Wide panel for longer text lines
1215
+ * const panel = renderMessagePanel(tileset, ['A much longer line of text here'], 7, 2);
1216
+ *
1217
+ * @example
1218
+ * // Extra tall 4-tile panel for extensive content (32 char rows)
1219
+ * const panel = renderMessagePanel(tileset, longContentArray, 5, 4);
1220
+ *
1221
+ * @example
1222
+ * // Text with ANSI colors (colors are preserved)
1223
+ * const coloredLines = [
1224
+ * '\x1b[33mWarning:\x1b[0m File not found',
1225
+ * '\x1b[32mSuccess:\x1b[0m Operation complete'
1226
+ * ];
1227
+ * const panel = renderMessagePanel(tileset, coloredLines, 5, 2);
1228
+ */
1229
+ function renderMessagePanel(tileset, textLines, widthTiles = 5, heightTiles = 2) {
1230
+ const topLeft = extractTile(tileset, DIALOGUE_TILES.TOP_LEFT);
1231
+ const topRight = extractTile(tileset, DIALOGUE_TILES.TOP_RIGHT);
1232
+ const bottomLeft = extractTile(tileset, DIALOGUE_TILES.BOTTOM_LEFT);
1233
+ const bottomRight = extractTile(tileset, DIALOGUE_TILES.BOTTOM_RIGHT);
1234
+ const tlRendered = renderTile(topLeft);
1235
+ const trRendered = renderTile(topRight);
1236
+ const blRendered = renderTile(bottomLeft);
1237
+ const brRendered = renderTile(bottomRight);
1238
+ // Create middle fill rows for top and bottom tiles
1239
+ const middleTopRendered = [];
1240
+ const middleBottomRendered = [];
1241
+ for (let row = 0; row < CHAR_HEIGHT; row++) {
1242
+ middleTopRendered.push(createMiddleFill(topLeft, row));
1243
+ middleBottomRendered.push(createMiddleFill(bottomLeft, row));
1244
+ }
1245
+ // Create middle row borders (for panels taller than 2 tiles)
1246
+ const middleRowBorders = [];
1247
+ for (let row = 0; row < CHAR_HEIGHT; row++) {
1248
+ middleRowBorders.push(createMiddleRowBorders(tileset, row));
1249
+ }
1250
+ const middleTiles = Math.max(0, widthTiles - 2);
1251
+ const interiorWidth = middleTiles * 16;
1252
+ const middleRows = Math.max(0, heightTiles - 2); // Number of middle tile rows
1253
+ // Sample background color from tile center
1254
+ const bgSamplePixel = topLeft[8][8];
1255
+ const textBgColor = `\x1b[48;2;${bgSamplePixel.r};${bgSamplePixel.g};${bgSamplePixel.b}m`;
1256
+ const boxLines = [];
1257
+ // Top row of tiles
1258
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
1259
+ let line = tlRendered[charRow];
1260
+ for (let m = 0; m < middleTiles; m++) {
1261
+ line += middleTopRendered[charRow];
1262
+ }
1263
+ line += trRendered[charRow];
1264
+ boxLines.push(line);
1265
+ }
1266
+ // Middle rows of tiles (for height > 2)
1267
+ for (let middleRowIdx = 0; middleRowIdx < middleRows; middleRowIdx++) {
1268
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
1269
+ const borders = middleRowBorders[charRow];
1270
+ let line = borders.left;
1271
+ for (let m = 0; m < middleTiles; m++) {
1272
+ line += borders.fill;
1273
+ }
1274
+ line += borders.right;
1275
+ boxLines.push(line);
1276
+ }
1277
+ }
1278
+ // Bottom row of tiles
1279
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
1280
+ let line = blRendered[charRow];
1281
+ for (let m = 0; m < middleTiles; m++) {
1282
+ line += middleBottomRendered[charRow];
1283
+ }
1284
+ line += brRendered[charRow];
1285
+ boxLines.push(line);
1286
+ }
1287
+ // Center text in the interior area only (excluding top and bottom tile rows for panels > 2 tiles)
1288
+ const boxHeight = CHAR_HEIGHT * heightTiles;
1289
+ // Calculate interior area where text can be placed
1290
+ let interiorStartRow;
1291
+ let interiorEndRow;
1292
+ if (heightTiles <= 2) {
1293
+ // For 2-tile panels: text can go in rows 4-11 (lower half of top tile, upper half of bottom tile)
1294
+ interiorStartRow = 2;
1295
+ interiorEndRow = boxHeight - 3;
1296
+ }
1297
+ else {
1298
+ // For 3+ tile panels: text can go in rows 2 through boxHeight-3
1299
+ // This allows text in top tile (rows 2-7), middle tiles (all rows), and bottom tile (rows 0-5)
1300
+ interiorStartRow = 2; // Leave 2 rows for top border
1301
+ interiorEndRow = boxHeight - 3; // Leave 2 rows for bottom border
1302
+ }
1303
+ const interiorHeight = interiorEndRow - interiorStartRow + 1;
1304
+ const textStartOffset = interiorStartRow + Math.floor((interiorHeight - textLines.length) / 2);
1305
+ for (let i = 0; i < textLines.length; i++) {
1306
+ const boxLineIndex = textStartOffset + i;
1307
+ // Only write text to rows within the interior area
1308
+ if (boxLineIndex >= interiorStartRow &&
1309
+ boxLineIndex <= interiorEndRow &&
1310
+ boxLineIndex < boxLines.length) {
1311
+ let line = textLines[i];
1312
+ let visibleLength = stripAnsi(line).length;
1313
+ // Truncate text if it exceeds interior width
1314
+ if (visibleLength > interiorWidth) {
1315
+ // Simple truncation - remove characters from the end
1316
+ // This handles ANSI codes by checking visible length
1317
+ let truncated = '';
1318
+ let truncatedVisible = 0;
1319
+ const maxLen = interiorWidth - 3; // Leave room for "..."
1320
+ for (let c = 0; c < line.length && truncatedVisible < maxLen; c++) {
1321
+ truncated += line[c];
1322
+ // Check if this added to visible length
1323
+ const newVisibleLen = stripAnsi(truncated).length;
1324
+ truncatedVisible = newVisibleLen;
1325
+ }
1326
+ line = `${truncated}...`;
1327
+ visibleLength = stripAnsi(line).length;
1328
+ }
1329
+ const padding = Math.max(0, Math.floor((interiorWidth - visibleLength) / 2));
1330
+ const rightPadding = Math.max(0, interiorWidth - padding - visibleLength);
1331
+ const textContent = ' '.repeat(padding) + line + ' '.repeat(rightPadding);
1332
+ const textWithBg = wrapTextWithBg(textContent, textBgColor);
1333
+ // Determine which tile row we're in and the char row within that tile
1334
+ const tileRowIdx = Math.floor(boxLineIndex / CHAR_HEIGHT);
1335
+ const charRow = boxLineIndex % CHAR_HEIGHT;
1336
+ // Get appropriate left/right borders based on tile row
1337
+ let leftBorder;
1338
+ let rightBorder;
1339
+ if (tileRowIdx === 0) {
1340
+ // Top tile row
1341
+ leftBorder = tlRendered[charRow];
1342
+ rightBorder = trRendered[charRow];
1343
+ }
1344
+ else if (tileRowIdx === heightTiles - 1) {
1345
+ // Bottom tile row
1346
+ leftBorder = blRendered[charRow];
1347
+ rightBorder = brRendered[charRow];
1348
+ }
1349
+ else {
1350
+ // Middle tile row
1351
+ const borders = middleRowBorders[charRow];
1352
+ leftBorder = borders.left;
1353
+ rightBorder = borders.right;
1354
+ }
1355
+ boxLines[boxLineIndex] = leftBorder + textWithBg + rightBorder;
1356
+ }
1357
+ }
1358
+ return boxLines;
1359
+ }
1360
+ function getMessagePrefix(msg) {
1361
+ if (msg.speaker === 'human') {
1362
+ return 'You: ';
1363
+ }
1364
+ else if (msg.speaker === 'arbiter') {
1365
+ return 'Arbiter: ';
1366
+ }
1367
+ else if (msg.speaker === 'orchestrator' && msg.orchestratorNumber) {
1368
+ return `Conjuring ${toRoman(msg.orchestratorNumber)}: `;
1369
+ }
1370
+ else if (msg.speaker === 'system') {
1371
+ return ''; // No prefix for system/narrator messages
1372
+ }
1373
+ return '';
1374
+ }
1375
+ function getMessageColor(speaker) {
1376
+ switch (speaker) {
1377
+ case 'human':
1378
+ return COLORS.human;
1379
+ case 'arbiter':
1380
+ return COLORS.arbiter;
1381
+ case 'orchestrator':
1382
+ return COLORS.orchestrator;
1383
+ case 'system':
1384
+ return COLORS.system;
1385
+ default:
1386
+ return COLORS.reset;
1387
+ }
1388
+ }
1389
+ function wrapText(text, width) {
1390
+ const lines = [];
1391
+ // First split by newlines to preserve paragraph breaks
1392
+ const paragraphs = text.split('\n');
1393
+ for (const paragraph of paragraphs) {
1394
+ const words = paragraph.split(' ');
1395
+ let currentLine = '';
1396
+ for (const word of words) {
1397
+ // Handle words longer than width by breaking them
1398
+ if (word.length > width) {
1399
+ if (currentLine) {
1400
+ lines.push(currentLine);
1401
+ currentLine = '';
1402
+ }
1403
+ // Break the long word into chunks
1404
+ for (let i = 0; i < word.length; i += width) {
1405
+ lines.push(word.substring(i, i + width));
1406
+ }
1407
+ continue;
1408
+ }
1409
+ if (currentLine.length + word.length + 1 <= width) {
1410
+ currentLine += (currentLine ? ' ' : '') + word;
1411
+ }
1412
+ else {
1413
+ if (currentLine)
1414
+ lines.push(currentLine);
1415
+ currentLine = word;
1416
+ }
1417
+ }
1418
+ if (currentLine)
1419
+ lines.push(currentLine);
1420
+ // Empty paragraph becomes empty line
1421
+ if (paragraph === '')
1422
+ lines.push('');
1423
+ }
1424
+ return lines.length > 0 ? lines : [''];
1425
+ }
1426
+ function addMessage(speaker, text, orchestratorNumber) {
1427
+ state.messages.push({
1428
+ id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
1429
+ speaker,
1430
+ orchestratorNumber,
1431
+ text,
1432
+ timestamp: new Date(),
1433
+ });
1434
+ // Set chat bubble for the speaker (clears any previous bubble)
1435
+ if (speaker === 'human') {
1436
+ state.sceneState.chatBubbleTarget = 'human';
1437
+ state.chatBubbleStartTime = Date.now();
1438
+ }
1439
+ else if (speaker === 'arbiter') {
1440
+ state.sceneState.chatBubbleTarget = 'arbiter';
1441
+ state.chatBubbleStartTime = Date.now();
1442
+ }
1443
+ else if (speaker === 'orchestrator') {
1444
+ state.sceneState.chatBubbleTarget = 'conjuring';
1445
+ state.chatBubbleStartTime = Date.now();
1446
+ }
1447
+ // system messages don't show a chat bubble
1448
+ // Auto-scroll to bottom using single source of truth
1449
+ const layout = getLayout(state.inputBuffer);
1450
+ const renderedLines = getRenderedChatLines(layout.chatArea.width);
1451
+ state.scrollOffset = Math.max(0, renderedLines.length - layout.chatArea.height);
1452
+ drawChat();
1453
+ drawTiles(true); // Force redraw tiles for chat bubble
1454
+ playSfx('quickNotice');
1455
+ }
1456
+ // ============================================================================
1457
+ // Input Handling
1458
+ // ============================================================================
1459
+ function handleKeypress(key) {
1460
+ if (state.mode === 'INSERT') {
1461
+ handleInsertModeKey(key);
1462
+ }
1463
+ else {
1464
+ handleNormalModeKey(key);
1465
+ }
1466
+ }
1467
+ /**
1468
+ * Calculate the visual line and column position for a cursor index
1469
+ * @param text The input buffer text
1470
+ * @param cursorPos The cursor position (index into text)
1471
+ * @param lineWidth The width of each line for wrapping
1472
+ * @returns { line: number, col: number, totalLines: number }
1473
+ */
1474
+ function getCursorLineCol(text, cursorPos, lineWidth) {
1475
+ if (!text || lineWidth <= 0) {
1476
+ return { line: 0, col: 0, totalLines: 1 };
1477
+ }
1478
+ const paragraphs = text.split('\n');
1479
+ let charIndex = 0;
1480
+ let lineIndex = 0;
1481
+ for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) {
1482
+ const para = paragraphs[pIdx];
1483
+ if (para.length === 0) {
1484
+ // Empty paragraph
1485
+ if (cursorPos === charIndex) {
1486
+ return { line: lineIndex, col: 0, totalLines: countTotalLines(text, lineWidth) };
1487
+ }
1488
+ lineIndex++;
1489
+ }
1490
+ else {
1491
+ // Non-empty paragraph - may wrap across multiple lines
1492
+ for (let i = 0; i < para.length; i += lineWidth) {
1493
+ const lineStart = charIndex + i;
1494
+ const lineEnd = charIndex + Math.min(i + lineWidth, para.length);
1495
+ if (cursorPos >= lineStart && cursorPos < lineEnd) {
1496
+ return {
1497
+ line: lineIndex,
1498
+ col: cursorPos - lineStart,
1499
+ totalLines: countTotalLines(text, lineWidth),
1500
+ };
1501
+ }
1502
+ // Cursor at end of this wrapped segment (and it's the last segment of paragraph)
1503
+ if (cursorPos === lineEnd && i + lineWidth >= para.length) {
1504
+ return {
1505
+ line: lineIndex,
1506
+ col: cursorPos - lineStart,
1507
+ totalLines: countTotalLines(text, lineWidth),
1508
+ };
1509
+ }
1510
+ lineIndex++;
1511
+ }
1512
+ charIndex += para.length;
1513
+ }
1514
+ // Handle newline between paragraphs
1515
+ if (pIdx < paragraphs.length - 1) {
1516
+ if (cursorPos === charIndex) {
1517
+ // Cursor at the newline - show at start of next line
1518
+ return { line: lineIndex, col: 0, totalLines: countTotalLines(text, lineWidth) };
1519
+ }
1520
+ charIndex++; // For the \n
1521
+ }
1522
+ }
1523
+ // Cursor at very end
1524
+ return {
1525
+ line: lineIndex - 1,
1526
+ col: text.length - charIndex + (paragraphs[paragraphs.length - 1]?.length || 0),
1527
+ totalLines: countTotalLines(text, lineWidth),
1528
+ };
1529
+ }
1530
+ /**
1531
+ * Count total wrapped lines for a text
1532
+ */
1533
+ function countTotalLines(text, lineWidth) {
1534
+ if (!text || lineWidth <= 0)
1535
+ return 1;
1536
+ const paragraphs = text.split('\n');
1537
+ let total = 0;
1538
+ for (const para of paragraphs) {
1539
+ total += para.length === 0 ? 1 : Math.ceil(para.length / lineWidth);
1540
+ }
1541
+ return Math.max(1, total);
1542
+ }
1543
+ /**
1544
+ * Convert a line/column position back to a cursor index
1545
+ * @param text The input buffer text
1546
+ * @param targetLine The target line number
1547
+ * @param targetCol The target column (will be clamped to line length)
1548
+ * @param lineWidth The width of each line for wrapping
1549
+ * @returns The cursor position (index into text)
1550
+ */
1551
+ function lineToCursorPos(text, targetLine, targetCol, lineWidth) {
1552
+ if (!text || lineWidth <= 0)
1553
+ return 0;
1554
+ const paragraphs = text.split('\n');
1555
+ let charIndex = 0;
1556
+ let lineIndex = 0;
1557
+ for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) {
1558
+ const para = paragraphs[pIdx];
1559
+ if (para.length === 0) {
1560
+ if (lineIndex === targetLine) {
1561
+ return charIndex; // Can only be at col 0 for empty line
1562
+ }
1563
+ lineIndex++;
1564
+ }
1565
+ else {
1566
+ for (let i = 0; i < para.length; i += lineWidth) {
1567
+ if (lineIndex === targetLine) {
1568
+ const lineLen = Math.min(lineWidth, para.length - i);
1569
+ const col = Math.min(targetCol, lineLen);
1570
+ return charIndex + i + col;
1571
+ }
1572
+ lineIndex++;
1573
+ }
1574
+ charIndex += para.length;
1575
+ }
1576
+ if (pIdx < paragraphs.length - 1) {
1577
+ charIndex++; // For the \n
1578
+ }
1579
+ }
1580
+ return text.length; // Default to end
1581
+ }
1582
+ function handleInsertModeKey(key) {
1583
+ switch (key) {
1584
+ case 'ESCAPE':
1585
+ state.mode = 'NORMAL';
1586
+ drawStatus(true);
1587
+ drawInput(true);
1588
+ break;
1589
+ case 'ENTER':
1590
+ // Check if character before cursor is a backslash (line continuation)
1591
+ if (state.cursorPos > 0 && state.inputBuffer[state.cursorPos - 1] === '\\') {
1592
+ // Remove the backslash and insert newline
1593
+ state.inputBuffer =
1594
+ state.inputBuffer.slice(0, state.cursorPos - 1) +
1595
+ '\n' +
1596
+ state.inputBuffer.slice(state.cursorPos);
1597
+ // cursorPos stays the same (backslash removed, newline added = net zero change)
1598
+ drawInput();
1599
+ break;
1600
+ }
1601
+ // Block input until arbiter has spoken first
1602
+ if (!state.arbiterHasSpoken) {
1603
+ break;
1604
+ }
1605
+ // Normal submit behavior
1606
+ if (state.inputBuffer.trim()) {
1607
+ const text = state.inputBuffer.trim();
1608
+ state.inputBuffer = '';
1609
+ state.cursorPos = 0;
1610
+ if (inputCallback) {
1611
+ inputCallback(text);
1612
+ }
1613
+ }
1614
+ drawInput(true);
1615
+ break;
1616
+ case 'BACKSPACE':
1617
+ if (state.cursorPos > 0) {
1618
+ // Delete character before cursor
1619
+ state.inputBuffer =
1620
+ state.inputBuffer.slice(0, state.cursorPos - 1) +
1621
+ state.inputBuffer.slice(state.cursorPos);
1622
+ state.cursorPos--;
1623
+ }
1624
+ drawInput();
1625
+ break;
1626
+ case 'DELETE':
1627
+ if (state.cursorPos < state.inputBuffer.length) {
1628
+ // Delete character at cursor
1629
+ state.inputBuffer =
1630
+ state.inputBuffer.slice(0, state.cursorPos) +
1631
+ state.inputBuffer.slice(state.cursorPos + 1);
1632
+ }
1633
+ drawInput();
1634
+ break;
1635
+ case 'LEFT':
1636
+ if (state.cursorPos > 0) {
1637
+ state.cursorPos--;
1638
+ drawInput();
1639
+ }
1640
+ break;
1641
+ case 'RIGHT':
1642
+ if (state.cursorPos < state.inputBuffer.length) {
1643
+ state.cursorPos++;
1644
+ drawInput();
1645
+ }
1646
+ break;
1647
+ case 'HOME':
1648
+ state.cursorPos = 0;
1649
+ drawInput();
1650
+ break;
1651
+ case 'END':
1652
+ state.cursorPos = state.inputBuffer.length;
1653
+ drawInput();
1654
+ break;
1655
+ case 'UP': {
1656
+ const layout = getLayout(state.inputBuffer);
1657
+ const textWidth = layout.inputArea.width - 3; // Match drawInput calculation
1658
+ const { line, col } = getCursorLineCol(state.inputBuffer, state.cursorPos, textWidth);
1659
+ if (line > 0) {
1660
+ // Move to previous line, same column (or end if shorter)
1661
+ state.cursorPos = lineToCursorPos(state.inputBuffer, line - 1, col, textWidth);
1662
+ drawInput();
1663
+ }
1664
+ break;
1665
+ }
1666
+ case 'DOWN': {
1667
+ const layout = getLayout(state.inputBuffer);
1668
+ const textWidth = layout.inputArea.width - 3;
1669
+ const { line, col, totalLines } = getCursorLineCol(state.inputBuffer, state.cursorPos, textWidth);
1670
+ if (line < totalLines - 1) {
1671
+ // Move to next line, same column (or end if shorter)
1672
+ state.cursorPos = lineToCursorPos(state.inputBuffer, line + 1, col, textWidth);
1673
+ drawInput();
1674
+ }
1675
+ break;
1676
+ }
1677
+ default:
1678
+ // Regular character
1679
+ if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
1680
+ // Insert character at cursor position
1681
+ state.inputBuffer =
1682
+ state.inputBuffer.slice(0, state.cursorPos) +
1683
+ key +
1684
+ state.inputBuffer.slice(state.cursorPos);
1685
+ state.cursorPos++;
1686
+ drawInput();
1687
+ }
1688
+ break;
1689
+ }
1690
+ }
1691
+ function handleNormalModeKey(key) {
1692
+ switch (key) {
1693
+ case 'i':
1694
+ case 'ENTER': {
1695
+ state.mode = 'INSERT';
1696
+ // Auto-scroll to bottom when entering insert mode
1697
+ const layoutIns = getLayout(state.inputBuffer);
1698
+ const renderedLinesIns = getRenderedChatLines(layoutIns.chatArea.width);
1699
+ state.scrollOffset = Math.max(0, renderedLinesIns.length - layoutIns.chatArea.height);
1700
+ drawChat();
1701
+ drawStatus(true);
1702
+ drawInput(true);
1703
+ break;
1704
+ }
1705
+ case 'j':
1706
+ case 'DOWN':
1707
+ // Scroll down
1708
+ state.scrollOffset++;
1709
+ drawChat();
1710
+ break;
1711
+ case 'k':
1712
+ case 'UP':
1713
+ // Scroll up
1714
+ state.scrollOffset = Math.max(0, state.scrollOffset - 1);
1715
+ drawChat();
1716
+ break;
1717
+ case 'g':
1718
+ // Scroll to top
1719
+ state.scrollOffset = 0;
1720
+ drawChat();
1721
+ break;
1722
+ case 'G': {
1723
+ // Scroll to bottom using single source of truth
1724
+ const layoutG = getLayout(state.inputBuffer);
1725
+ const renderedLinesG = getRenderedChatLines(layoutG.chatArea.width);
1726
+ state.scrollOffset = Math.max(0, renderedLinesG.length - layoutG.chatArea.height);
1727
+ drawChat();
1728
+ break;
1729
+ }
1730
+ case 'b':
1731
+ case 'CTRL_B': {
1732
+ // Page up (back)
1733
+ const layoutB = getLayout(state.inputBuffer);
1734
+ state.scrollOffset = Math.max(0, state.scrollOffset - layoutB.chatArea.height);
1735
+ drawChat();
1736
+ break;
1737
+ }
1738
+ case 'f':
1739
+ case 'CTRL_F': {
1740
+ // Page down (forward)
1741
+ const layoutF = getLayout(state.inputBuffer);
1742
+ const renderedLinesF = getRenderedChatLines(layoutF.chatArea.width);
1743
+ const maxScrollF = Math.max(0, renderedLinesF.length - layoutF.chatArea.height);
1744
+ state.scrollOffset = Math.min(maxScrollF, state.scrollOffset + layoutF.chatArea.height);
1745
+ drawChat();
1746
+ break;
1747
+ }
1748
+ case 'u':
1749
+ case 'CTRL_U': {
1750
+ // Half page up
1751
+ const layoutU = getLayout(state.inputBuffer);
1752
+ const halfPageU = Math.floor(layoutU.chatArea.height / 2);
1753
+ state.scrollOffset = Math.max(0, state.scrollOffset - halfPageU);
1754
+ drawChat();
1755
+ break;
1756
+ }
1757
+ case 'd':
1758
+ case 'CTRL_D': {
1759
+ // Half page down
1760
+ const layoutD = getLayout(state.inputBuffer);
1761
+ const renderedLinesD = getRenderedChatLines(layoutD.chatArea.width);
1762
+ const maxScrollD = Math.max(0, renderedLinesD.length - layoutD.chatArea.height);
1763
+ const halfPageD = Math.floor(layoutD.chatArea.height / 2);
1764
+ state.scrollOffset = Math.min(maxScrollD, state.scrollOffset + halfPageD);
1765
+ drawChat();
1766
+ break;
1767
+ }
1768
+ case 'o':
1769
+ // Open debug log with less
1770
+ openLogViewer();
1771
+ break;
1772
+ default:
1773
+ break;
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Opens a simple built-in log viewer (avoids signal handling issues with less)
1778
+ */
1779
+ function openLogViewer() {
1780
+ // Check if log file exists
1781
+ if (!fs.existsSync(DEBUG_LOG_PATH)) {
1782
+ return;
1783
+ }
1784
+ inLogViewer = true;
1785
+ // Read the log file
1786
+ let logContent;
1787
+ try {
1788
+ logContent = fs.readFileSync(DEBUG_LOG_PATH, 'utf-8');
1789
+ }
1790
+ catch (_err) {
1791
+ inLogViewer = false;
1792
+ return;
1793
+ }
1794
+ const logLines = logContent.split('\n');
1795
+ let logScrollOffset = Math.max(0, logLines.length - (term.height - 2)); // Start at bottom
1796
+ function drawLogViewer() {
1797
+ term.clear();
1798
+ const visibleLines = term.height - 2; // Leave room for header and footer
1799
+ // Header - green like INSERT mode
1800
+ term.moveTo(1, 1);
1801
+ process.stdout.write('\x1b[42;30m DEBUG LOG \x1b[0m');
1802
+ process.stdout.write(`\x1b[2m (${logLines.length} lines, showing ${logScrollOffset + 1}-${Math.min(logScrollOffset + visibleLines, logLines.length)})\x1b[0m`);
1803
+ // Log content
1804
+ for (let i = 0; i < visibleLines; i++) {
1805
+ const lineIdx = logScrollOffset + i;
1806
+ term.moveTo(1, i + 2);
1807
+ term.eraseLine();
1808
+ if (lineIdx < logLines.length) {
1809
+ // Truncate long lines and display with default colors
1810
+ const line = logLines[lineIdx].substring(0, term.width - 1);
1811
+ process.stdout.write(`\x1b[0m${line}`);
1812
+ }
1813
+ }
1814
+ // Footer - green like INSERT mode
1815
+ term.moveTo(1, term.height);
1816
+ process.stdout.write('\x1b[42;30m j/k:line u/d:half b/f:page g/G:top/bottom q:close ^C:quit ^Z:suspend \x1b[0m');
1817
+ }
1818
+ drawLogViewer();
1819
+ // Handle log viewer keys
1820
+ // (animation keeps running in background so state stays current)
1821
+ const logKeyHandler = (key) => {
1822
+ if (key === 'q' || key === 'ESCAPE') {
1823
+ // Close log viewer
1824
+ term.off('key', logKeyHandler);
1825
+ inLogViewer = false;
1826
+ fullDraw();
1827
+ return;
1828
+ }
1829
+ if (key === 'CTRL_C') {
1830
+ // Close log viewer and show exit prompt
1831
+ term.off('key', logKeyHandler);
1832
+ inLogViewer = false;
1833
+ fullDraw();
1834
+ state.pendingExit = true;
1835
+ drawStatus(true);
1836
+ return;
1837
+ }
1838
+ if (key === 'CTRL_Z') {
1839
+ // Close log viewer and suspend
1840
+ term.off('key', logKeyHandler);
1841
+ inLogViewer = false;
1842
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
1843
+ process.stdin.setRawMode(false);
1844
+ }
1845
+ process.removeAllListeners('SIGTSTP');
1846
+ process.kill(0, 'SIGTSTP');
1847
+ return;
1848
+ }
1849
+ // Note: CTRL_BACKSLASH is handled via raw stdin listener in start()
1850
+ const visibleLines = term.height - 2;
1851
+ const halfPage = Math.floor(visibleLines / 2);
1852
+ const maxScroll = Math.max(0, logLines.length - visibleLines);
1853
+ if (key === 'j' || key === 'DOWN') {
1854
+ logScrollOffset = Math.min(maxScroll, logScrollOffset + 1);
1855
+ drawLogViewer();
1856
+ }
1857
+ else if (key === 'k' || key === 'UP') {
1858
+ logScrollOffset = Math.max(0, logScrollOffset - 1);
1859
+ drawLogViewer();
1860
+ }
1861
+ else if (key === 'g') {
1862
+ logScrollOffset = 0;
1863
+ drawLogViewer();
1864
+ }
1865
+ else if (key === 'G') {
1866
+ logScrollOffset = maxScroll;
1867
+ drawLogViewer();
1868
+ }
1869
+ else if (key === 'u') {
1870
+ // Half page up
1871
+ logScrollOffset = Math.max(0, logScrollOffset - halfPage);
1872
+ drawLogViewer();
1873
+ }
1874
+ else if (key === 'd') {
1875
+ // Half page down
1876
+ logScrollOffset = Math.min(maxScroll, logScrollOffset + halfPage);
1877
+ drawLogViewer();
1878
+ }
1879
+ else if (key === 'b' || key === 'PAGE_UP') {
1880
+ // Full page up
1881
+ logScrollOffset = Math.max(0, logScrollOffset - visibleLines);
1882
+ drawLogViewer();
1883
+ }
1884
+ else if (key === 'f' || key === 'PAGE_DOWN') {
1885
+ // Full page down
1886
+ logScrollOffset = Math.min(maxScroll, logScrollOffset + visibleLines);
1887
+ drawLogViewer();
1888
+ }
1889
+ };
1890
+ term.on('key', logKeyHandler);
1891
+ }
1892
+ // ============================================================================
1893
+ // Animation
1894
+ // ============================================================================
1895
+ /**
1896
+ * Get the current position (row, col) of a character type
1897
+ */
1898
+ function getCharacterPosition(target) {
1899
+ if (target === 'human') {
1900
+ return { row: 2, col: state.sceneState.humanCol };
1901
+ }
1902
+ else if (target === 'arbiter') {
1903
+ // Map arbiterPos to actual row/col
1904
+ const pos = state.sceneState.arbiterPos;
1905
+ switch (pos) {
1906
+ case 0:
1907
+ return { row: 2, col: 3 }; // By scroll (final position)
1908
+ case 1:
1909
+ return { row: 2, col: 4 }; // By cauldron
1910
+ case 2:
1911
+ return { row: 3, col: 4 }; // By fire (starting position)
1912
+ default:
1913
+ return { row: 2, col: 3 }; // fallback
1914
+ }
1915
+ }
1916
+ else {
1917
+ // conjuring = first demon at row 2, col 6
1918
+ return { row: 2, col: 6 };
1919
+ }
1920
+ }
1921
+ /**
1922
+ * Trigger a hop animation at a specific position
1923
+ * @param row Tile row
1924
+ * @param col Tile column
1925
+ * @param count Number of hops (default 1)
1926
+ */
1927
+ function triggerHop(row, col, count = 1) {
1928
+ const key = `${row},${col}`;
1929
+ state.sceneState.activeHops.set(key, {
1930
+ remaining: count,
1931
+ frameInHop: 0, // Start in "up" position
1932
+ });
1933
+ drawTiles(true);
1934
+ }
1935
+ /**
1936
+ * Trigger hop by character name (convenience wrapper)
1937
+ */
1938
+ function triggerCharacterHop(target, count = 1) {
1939
+ const pos = getCharacterPosition(target);
1940
+ triggerHop(pos.row, pos.col, count);
1941
+ }
1942
+ /**
1943
+ * Stop all active hops
1944
+ */
1945
+ function clearAllHops() {
1946
+ state.sceneState.activeHops.clear();
1947
+ drawTiles(true);
1948
+ }
1949
+ /**
1950
+ * Tick all active hop animations (called every ANIMATION_INTERVAL)
1951
+ * Each hop = 2 frames (up on frame 0, down on frame 1)
1952
+ */
1953
+ function tickHops() {
1954
+ let anyActive = false;
1955
+ const toRemove = [];
1956
+ for (const [key, hopState] of state.sceneState.activeHops) {
1957
+ anyActive = true;
1958
+ // Advance frame within current hop
1959
+ if (hopState.frameInHop === 0) {
1960
+ // Was up, now go down
1961
+ playSfx('jump');
1962
+ hopState.frameInHop = 1;
1963
+ }
1964
+ else {
1965
+ // Was down, complete this hop
1966
+ hopState.remaining--;
1967
+ if (hopState.remaining <= 0) {
1968
+ toRemove.push(key);
1969
+ }
1970
+ else {
1971
+ // Start next hop
1972
+ hopState.frameInHop = 0;
1973
+ }
1974
+ }
1975
+ }
1976
+ // Remove completed hops
1977
+ for (const key of toRemove) {
1978
+ state.sceneState.activeHops.delete(key);
1979
+ }
1980
+ return anyActive || toRemove.length > 0;
1981
+ }
1982
+ function startAnimation() {
1983
+ animationInterval = setInterval(() => {
1984
+ // State updates always run (timers, counters) even when drawing disabled
1985
+ state.animationFrame = (state.animationFrame + 1) % 8;
1986
+ // Increment blink cycle every full animation cycle (for slower chat blink)
1987
+ if (state.animationFrame === 0) {
1988
+ state.blinkCycle = (state.blinkCycle + 1) % 8;
1989
+ }
1990
+ // Auto-clear expired tool indicator
1991
+ if (state.currentTool && Date.now() - state.lastToolTime > 5000) {
1992
+ state.currentTool = null;
1993
+ state.toolCallCount = 0;
1994
+ }
1995
+ // Auto-clear chat bubble after 5 seconds
1996
+ let bubbleCleared = false;
1997
+ if (state.sceneState.chatBubbleTarget && Date.now() - state.chatBubbleStartTime > 5000) {
1998
+ state.sceneState.chatBubbleTarget = null;
1999
+ bubbleCleared = true;
2000
+ }
2001
+ // Tick hop animations
2002
+ const hasHops = tickHops();
2003
+ // Animate bubbles when waiting (toggle every ~1 second based on animationFrame)
2004
+ if (state.waitingFor !== 'none') {
2005
+ // Show bubbles for frames 0-3, hide for frames 4-7 (toggles every 1 second)
2006
+ state.sceneState.bubbleVisible = state.animationFrame < 4;
2007
+ }
2008
+ // Skip actual drawing when disabled (suspended/detached) or no TTY
2009
+ if (!state.drawingEnabled || !process.stdout.isTTY)
2010
+ return;
2011
+ // Draw if animations are active or bubble just cleared
2012
+ if (hasHops || state.waitingFor !== 'none' || bubbleCleared) {
2013
+ drawTiles();
2014
+ drawChat(); // Update chat working indicator
2015
+ }
2016
+ }, ANIMATION_INTERVAL);
2017
+ }
2018
+ function stopAnimation() {
2019
+ if (animationInterval) {
2020
+ clearInterval(animationInterval);
2021
+ animationInterval = null;
2022
+ }
2023
+ }
2024
+ /**
2025
+ * Animate the arbiter walking to a target position
2026
+ * Old arbiter shuffles slowly - 1 second per step
2027
+ *
2028
+ * Position mapping:
2029
+ * - Pos 2: by fire (row 3, col 4) - starting position
2030
+ * - Pos 1: by cauldron (row 2, col 4)
2031
+ * - Pos 0: by scroll (row 2, col 3) - final position
2032
+ */
2033
+ function animateArbiterWalk(targetPos, onComplete) {
2034
+ // Clear any existing walk animation
2035
+ if (arbiterWalkInterval) {
2036
+ clearInterval(arbiterWalkInterval);
2037
+ arbiterWalkInterval = null;
2038
+ }
2039
+ const currentPos = state.sceneState.arbiterPos;
2040
+ if (currentPos === targetPos) {
2041
+ onComplete?.();
2042
+ return;
2043
+ }
2044
+ const direction = targetPos > currentPos ? 1 : -1;
2045
+ arbiterWalkInterval = setInterval(() => {
2046
+ const newPos = state.sceneState.arbiterPos + direction;
2047
+ // Type-safe position clamping
2048
+ if (newPos >= -1 && newPos <= 2) {
2049
+ state.sceneState.arbiterPos = newPos;
2050
+ playSfx('footstep');
2051
+ drawTiles(true); // Force redraw for walk animation
2052
+ if (newPos === targetPos) {
2053
+ clearInterval(arbiterWalkInterval);
2054
+ arbiterWalkInterval = null;
2055
+ onComplete?.();
2056
+ }
2057
+ }
2058
+ else {
2059
+ clearInterval(arbiterWalkInterval);
2060
+ arbiterWalkInterval = null;
2061
+ onComplete?.();
2062
+ }
2063
+ }, 1000); // 1 second per step, 3 seconds for full walk
2064
+ }
2065
+ // ============================================================================
2066
+ // Summon Sequence Functions
2067
+ // ============================================================================
2068
+ /**
2069
+ * Start the summon sequence: walk to fire position, show spellbook, then process demon queue.
2070
+ * Called when mode changes to 'arbiter_to_orchestrator'.
2071
+ * Reuses animateArbiterWalk for the walking animation.
2072
+ */
2073
+ function startSummonSequence() {
2074
+ if (summonState !== 'idle')
2075
+ return; // Already summoning or dismissing
2076
+ summonState = 'walking';
2077
+ animateArbiterWalk(2, () => {
2078
+ // Pos 2 = by fire (row 3, col 4)
2079
+ // Walk complete - show spellbook after brief pause
2080
+ setTimeout(() => {
2081
+ state.sceneState.showSpellbook = true;
2082
+ drawTiles(true);
2083
+ summonState = 'ready';
2084
+ // Process any demons that queued during the walk
2085
+ processQueuedSpawns();
2086
+ }, ANIMATION_INTERVAL);
2087
+ });
2088
+ }
2089
+ /**
2090
+ * Queue a demon to spawn. If already at cauldron (ready), shows immediately.
2091
+ * If still walking, demon appears after walk + spellbook sequence completes.
2092
+ */
2093
+ function queueDemonSpawn(demonNumber) {
2094
+ pendingDemonSpawns.push(demonNumber);
2095
+ if (summonState === 'ready') {
2096
+ // Already at cauldron with spellbook, process immediately
2097
+ processQueuedSpawns();
2098
+ }
2099
+ else if (summonState === 'idle') {
2100
+ // Not at cauldron yet, start the sequence
2101
+ startSummonSequence();
2102
+ }
2103
+ // If 'walking' or 'dismissing', demon will be processed when sequence completes
2104
+ }
2105
+ /**
2106
+ * Process demons from the queue, showing them one at a time with a pause between each.
2107
+ */
2108
+ function processQueuedSpawns() {
2109
+ if (summonState !== 'ready')
2110
+ return;
2111
+ if (pendingDemonSpawns.length === 0)
2112
+ return;
2113
+ const nextDemon = pendingDemonSpawns.shift();
2114
+ // Brief pause, then show demon
2115
+ setTimeout(() => {
2116
+ state.sceneState.demonCount = nextDemon;
2117
+ playSfx('magic');
2118
+ drawTiles(true);
2119
+ // Process next demon if any (recursive with delay)
2120
+ if (pendingDemonSpawns.length > 0) {
2121
+ processQueuedSpawns();
2122
+ }
2123
+ }, ANIMATION_INTERVAL * 2); // ~500ms pause before each demon appears
2124
+ }
2125
+ /**
2126
+ * Start the dismiss sequence: hide spellbook, clear demons, walk back to human.
2127
+ * Called when orchestrators are disconnected or mode changes back.
2128
+ */
2129
+ function startDismissSequence() {
2130
+ // Clear any pending spawns
2131
+ pendingDemonSpawns = [];
2132
+ // If already idle or dismissing, nothing to do
2133
+ if (summonState === 'idle' || summonState === 'dismissing')
2134
+ return;
2135
+ summonState = 'dismissing';
2136
+ // Hide spellbook and clear demons immediately
2137
+ state.sceneState.showSpellbook = false;
2138
+ state.sceneState.demonCount = 0;
2139
+ drawTiles(true);
2140
+ // Walk back after brief pause
2141
+ setTimeout(() => {
2142
+ animateArbiterWalk(0, () => {
2143
+ summonState = 'idle';
2144
+ });
2145
+ }, ANIMATION_INTERVAL);
2146
+ }
2147
+ /**
2148
+ * Start the arbiter walk animation from fire to human position.
2149
+ * Called either directly from entrance sequence (no requirements prompt)
2150
+ * or after user selects a requirements file.
2151
+ */
2152
+ function startArbiterWalk() {
2153
+ animateArbiterWalk(0, () => {
2154
+ // Arbiter arrived at scroll - show alert/exclamation indicator
2155
+ state.sceneState.alertTarget = 'arbiter';
2156
+ drawTiles(true);
2157
+ // Pause to let user see the alert (~1.5 seconds)
2158
+ setTimeout(() => {
2159
+ state.sceneState.alertTarget = null;
2160
+ drawTiles(true);
2161
+ // Now complete entrance and show message
2162
+ entranceComplete = true;
2163
+ if (pendingArbiterMessage) {
2164
+ addMessage('arbiter', pendingArbiterMessage);
2165
+ pendingArbiterMessage = null;
2166
+ }
2167
+ }, 1500);
2168
+ });
2169
+ }
2170
+ /**
2171
+ * Run the full entrance sequence:
2172
+ * 1. Human walks in from left (col 0 → 1)
2173
+ * 2. Human hops twice (surprised)
2174
+ * 3. Arbiter hops twice (notices visitor)
2175
+ * 4. Requirements prompt shows (if needed) - arbiter still at starting position
2176
+ * 5. After user selects file (or if no prompt needed), arbiter walks to human
2177
+ */
2178
+ function runEntranceSequence() {
2179
+ // Show "the arbiter approaches" message
2180
+ addMessage('system', 'The arbiter approaches...');
2181
+ // Timeline:
2182
+ // 0ms: human at col 0
2183
+ // 400ms: human walks to col 1
2184
+ // 900ms: human hops twice (takes 1000ms, ends ~1900ms)
2185
+ // 1800ms: arbiter hops twice (takes 1000ms, ends ~2800ms)
2186
+ // 2800ms: requirements prompt shows (if needed), OR arbiter starts walking
2187
+ // After user selects file: arbiter walks
2188
+ // Step 1: Human walks in (already at col 0, moves to col 1)
2189
+ setTimeout(() => {
2190
+ if (!isRunning)
2191
+ return;
2192
+ state.sceneState.humanCol = 1;
2193
+ drawTiles(true);
2194
+ }, 400);
2195
+ // Step 2: Human hops twice (surprised to see the arbiter)
2196
+ setTimeout(() => {
2197
+ if (!isRunning)
2198
+ return;
2199
+ triggerCharacterHop('human', 2);
2200
+ }, 900);
2201
+ // Step 3: Arbiter hops twice at fire position (notices the visitor)
2202
+ // Wait longer so human finishes hopping first
2203
+ setTimeout(() => {
2204
+ if (!isRunning)
2205
+ return;
2206
+ triggerCharacterHop('arbiter', 2);
2207
+ }, 1800);
2208
+ // Step 4: After hops complete (~2800ms), check if we need requirements prompt
2209
+ setTimeout(() => {
2210
+ if (!isRunning)
2211
+ return;
2212
+ if (needsRequirementsPrompt) {
2213
+ // Show requirements overlay BEFORE arbiter walks
2214
+ state.requirementsOverlay = 'prompt';
2215
+ _inRequirementsOverlay = true; // Prevent animation interval from overwriting
2216
+ loadFilesForPicker(); // Pre-load files
2217
+ drawRequirementsOverlay();
2218
+ // Arbiter will walk after user selects file (in selectRequirementsFile)
2219
+ }
2220
+ else {
2221
+ // No requirements prompt needed (CLI arg provided)
2222
+ // Signal ready and start walk
2223
+ if (requirementsReadyCallback) {
2224
+ requirementsReadyCallback();
2225
+ requirementsReadyCallback = null;
2226
+ }
2227
+ startArbiterWalk();
2228
+ }
2229
+ }, 2800);
2230
+ }
2231
+ // ============================================================================
2232
+ // Router Callbacks
2233
+ // ============================================================================
2234
+ function getRouterCallbacks() {
2235
+ return {
2236
+ onHumanMessage: (text) => {
2237
+ addMessage('human', text);
2238
+ // Hide tool indicator on message
2239
+ state.showToolIndicator = false;
2240
+ state.recentTools = [];
2241
+ state.toolCountSinceLastMessage = 0;
2242
+ },
2243
+ onArbiterMessage: (text) => {
2244
+ // If entrance animation isn't complete, queue the message
2245
+ if (!entranceComplete) {
2246
+ pendingArbiterMessage = text;
2247
+ }
2248
+ else {
2249
+ addMessage('arbiter', text);
2250
+ }
2251
+ // Unlock input now that arbiter has spoken
2252
+ state.arbiterHasSpoken = true;
2253
+ // Hide tool indicator on message
2254
+ state.showToolIndicator = false;
2255
+ state.recentTools = [];
2256
+ state.toolCountSinceLastMessage = 0;
2257
+ },
2258
+ onOrchestratorMessage: (orchestratorNumber, text) => {
2259
+ addMessage('orchestrator', text, orchestratorNumber);
2260
+ // Hide tool indicator on message
2261
+ state.showToolIndicator = false;
2262
+ state.recentTools = [];
2263
+ state.toolCountSinceLastMessage = 0;
2264
+ },
2265
+ onContextUpdate: (arbiterPercent, orchestratorPercent) => {
2266
+ state.arbiterContextPercent = arbiterPercent;
2267
+ state.orchestratorContextPercent = orchestratorPercent;
2268
+ drawContext();
2269
+ },
2270
+ onToolUse: (tool, count) => {
2271
+ state.currentTool = tool;
2272
+ state.toolCallCount = count;
2273
+ state.lastToolTime = Date.now();
2274
+ // Update tool indicator state
2275
+ state.toolCountSinceLastMessage++;
2276
+ state.showToolIndicator = true;
2277
+ // Keep last 2 tools
2278
+ if (state.recentTools.length === 0 ||
2279
+ state.recentTools[state.recentTools.length - 1] !== tool) {
2280
+ state.recentTools.push(tool);
2281
+ if (state.recentTools.length > 2) {
2282
+ state.recentTools.shift();
2283
+ }
2284
+ }
2285
+ drawContext();
2286
+ drawChat(); // Also redraw chat for tool indicator
2287
+ },
2288
+ onModeChange: (mode) => {
2289
+ // Use summon/dismiss sequences for coordinated animation
2290
+ if (mode === 'arbiter_to_orchestrator') {
2291
+ // Start walking to cauldron - demons will appear after walk + spellbook
2292
+ startSummonSequence();
2293
+ }
2294
+ else {
2295
+ // Leaving work position - hide spellbook, clear demons, walk back
2296
+ startDismissSequence();
2297
+ }
2298
+ },
2299
+ onWaitingStart: (waitingFor) => {
2300
+ // Ignore waiting during entrance sequence - don't want arbiter hopping early
2301
+ if (!entranceComplete)
2302
+ return;
2303
+ state.waitingFor = waitingFor;
2304
+ // Hop for 3 seconds (6 hops at 500ms each)
2305
+ const target = waitingFor === 'arbiter' ? 'arbiter' : 'conjuring';
2306
+ triggerCharacterHop(target, 6);
2307
+ // Turn on bubbles (stays on until work is done)
2308
+ state.sceneState.bubbleVisible = true;
2309
+ drawTiles(true);
2310
+ drawChat(true);
2311
+ },
2312
+ onWaitingStop: () => {
2313
+ state.waitingFor = 'none';
2314
+ // Clear any remaining hops
2315
+ clearAllHops();
2316
+ // Turn off bubbles
2317
+ state.sceneState.bubbleVisible = false;
2318
+ drawTiles(true);
2319
+ drawChat(true);
2320
+ },
2321
+ onOrchestratorSpawn: (orchestratorNumber) => {
2322
+ // Queue the demon to appear after walk + spellbook sequence completes
2323
+ queueDemonSpawn(orchestratorNumber);
2324
+ },
2325
+ onOrchestratorDisconnect: () => {
2326
+ // Run dismiss sequence (clears demons, hides spellbook, walks back)
2327
+ startDismissSequence();
2328
+ // Also clear orchestrator UI state
2329
+ state.orchestratorContextPercent = null;
2330
+ state.currentTool = null;
2331
+ state.toolCallCount = 0;
2332
+ drawContext();
2333
+ },
2334
+ onCrashCountUpdate: (count) => {
2335
+ state.crashCount = count;
2336
+ drawContext(true); // Force redraw
2337
+ },
2338
+ onDebugLog: (entry) => {
2339
+ // Write to debug log file
2340
+ const timestamp = new Date().toISOString();
2341
+ let logLine = `[${timestamp}] [${entry.type.toUpperCase()}]`;
2342
+ if (entry.agent) {
2343
+ logLine += ` [${entry.agent}]`;
2344
+ }
2345
+ if (entry.speaker) {
2346
+ logLine += ` ${entry.speaker}:`;
2347
+ }
2348
+ if (entry.messageType) {
2349
+ logLine += ` (${entry.messageType})`;
2350
+ }
2351
+ logLine += ` ${entry.text}`;
2352
+ // Add full details as JSON if present
2353
+ if (entry.details) {
2354
+ logLine += `\n DETAILS: ${JSON.stringify(entry.details, null, 2).split('\n').join('\n ')}`;
2355
+ }
2356
+ logLine += '\n';
2357
+ // Ensure directory exists and append to file
2358
+ try {
2359
+ const dir = path.dirname(DEBUG_LOG_PATH);
2360
+ if (!fs.existsSync(dir)) {
2361
+ fs.mkdirSync(dir, { recursive: true });
2362
+ }
2363
+ fs.appendFileSync(DEBUG_LOG_PATH, logLine);
2364
+ }
2365
+ catch (_err) {
2366
+ // Silently ignore write errors
2367
+ }
2368
+ },
2369
+ };
2370
+ }
2371
+ // ============================================================================
2372
+ // Public Interface
2373
+ // ============================================================================
2374
+ async function start() {
2375
+ if (isRunning)
2376
+ return;
2377
+ isRunning = true;
2378
+ // Remove any existing SIGINT handlers (terminal-kit or others may add them)
2379
+ process.removeAllListeners('SIGINT');
2380
+ // Set up global SIGINT handler to prevent default exit behavior
2381
+ // terminal-kit handles CTRL_C as a key event, but this catches any SIGINT that slips through
2382
+ process.on('SIGINT', () => {
2383
+ if (!state.pendingExit) {
2384
+ state.pendingExit = true;
2385
+ drawStatus(true);
2386
+ }
2387
+ });
2388
+ // Handle SIGCONT (resume after suspend or dtach reattach) - restore TUI state
2389
+ process.on('SIGCONT', () => {
2390
+ // Toggle raw mode off/on to reset termios (workaround for OS resetting attrs)
2391
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
2392
+ process.stdin.setRawMode(false);
2393
+ process.stdin.setRawMode(true);
2394
+ }
2395
+ // Re-init terminal-kit and redraw
2396
+ term.grabInput(true);
2397
+ term.fullscreen(true);
2398
+ term.hideCursor();
2399
+ // Re-enable drawing and do a clean full redraw
2400
+ state.drawingEnabled = true;
2401
+ fullDraw();
2402
+ });
2403
+ // Handle SIGHUP (dtach reattach may send this)
2404
+ process.on('SIGHUP', () => {
2405
+ // Toggle raw mode and redraw - terminal might have reconnected
2406
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
2407
+ process.stdin.setRawMode(false);
2408
+ process.stdin.setRawMode(true);
2409
+ }
2410
+ term.grabInput(true);
2411
+ fullDraw();
2412
+ });
2413
+ // Handle SIGWINCH (terminal resize, dtach sends this on reattach with REDRAW_WINCH)
2414
+ process.on('SIGWINCH', () => {
2415
+ // Toggle raw mode and redraw
2416
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
2417
+ process.stdin.setRawMode(false);
2418
+ process.stdin.setRawMode(true);
2419
+ }
2420
+ term.grabInput(true);
2421
+ fullDraw();
2422
+ });
2423
+ // Clear the debug log file for this session
2424
+ try {
2425
+ const dir = path.dirname(DEBUG_LOG_PATH);
2426
+ if (!fs.existsSync(dir)) {
2427
+ fs.mkdirSync(dir, { recursive: true });
2428
+ }
2429
+ fs.writeFileSync(DEBUG_LOG_PATH, `=== Arbiter Session ${new Date().toISOString()} ===\n\n`);
2430
+ }
2431
+ catch (_err) {
2432
+ // Ignore errors
2433
+ }
2434
+ // Load tileset
2435
+ try {
2436
+ state.tileset = await loadTileset();
2437
+ }
2438
+ catch (err) {
2439
+ console.error('Failed to load tileset:', err);
2440
+ throw err;
2441
+ }
2442
+ // Enter fullscreen mode
2443
+ term.fullscreen(true);
2444
+ term.hideCursor();
2445
+ // Initial draw (character at column 0 - entering from the path)
2446
+ fullDraw();
2447
+ // Start animation timer
2448
+ startAnimation();
2449
+ // Always run the full entrance sequence
2450
+ // Human walks in, both characters hop, arbiter walks to human
2451
+ // Requirements overlay shows AFTER entrance completes (if no CLI arg)
2452
+ runEntranceSequence();
2453
+ // Set up input handling
2454
+ term.grabInput(true);
2455
+ // Helper to properly suspend the process
2456
+ const suspendProcess = () => {
2457
+ // Disable drawing first to prevent any in-flight draws
2458
+ state.drawingEnabled = false;
2459
+ // Restore terminal to cooked mode
2460
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
2461
+ process.stdin.setRawMode(false);
2462
+ }
2463
+ // Remove any SIGTSTP listeners so the default suspend behavior happens
2464
+ process.removeAllListeners('SIGTSTP');
2465
+ // Send SIGTSTP to process group (0) for proper job control
2466
+ process.kill(0, 'SIGTSTP');
2467
+ };
2468
+ // Raw stdin handler for Ctrl-\ (0x1c) - terminal-kit doesn't emit this as a key
2469
+ // This is used for dtach detachment
2470
+ process.stdin.on('data', (data) => {
2471
+ if (data.includes(0x1c)) {
2472
+ // Ctrl-\ = ASCII 28 = 0x1c
2473
+ // Disable drawing before detach (same as suspend)
2474
+ state.drawingEnabled = false;
2475
+ process.kill(process.pid, 'SIGQUIT');
2476
+ }
2477
+ });
2478
+ term.on('key', (key) => {
2479
+ // Handle requirements overlay first (takes priority)
2480
+ if (state.requirementsOverlay !== 'none') {
2481
+ // Allow CTRL_C to exit even during overlay
2482
+ if (key === 'CTRL_C') {
2483
+ state.pendingExit = true;
2484
+ drawStatus(true);
2485
+ return;
2486
+ }
2487
+ // Allow CTRL_Z to suspend during overlay
2488
+ if (key === 'CTRL_Z') {
2489
+ suspendProcess();
2490
+ return;
2491
+ }
2492
+ requirementsKeyHandler(key);
2493
+ return;
2494
+ }
2495
+ // Handle exit confirmation mode
2496
+ if (state.pendingExit) {
2497
+ if (key === 'y' || key === 'Y') {
2498
+ // Call exit callback if registered, otherwise exit directly
2499
+ if (exitCallback) {
2500
+ exitCallback();
2501
+ }
2502
+ else {
2503
+ stop();
2504
+ process.exit(0);
2505
+ }
2506
+ }
2507
+ else {
2508
+ // Cancel exit
2509
+ state.pendingExit = false;
2510
+ drawStatus(true);
2511
+ }
2512
+ return;
2513
+ }
2514
+ if (key === 'CTRL_C') {
2515
+ // Show exit confirmation
2516
+ state.pendingExit = true;
2517
+ drawStatus(true);
2518
+ return;
2519
+ }
2520
+ if (key === 'CTRL_Z') {
2521
+ suspendProcess();
2522
+ return;
2523
+ }
2524
+ // Note: CTRL_BACKSLASH (Ctrl-\) is handled via raw stdin listener above
2525
+ handleKeypress(key);
2526
+ });
2527
+ // Handle resize
2528
+ term.on('resize', () => {
2529
+ fullDraw();
2530
+ });
2531
+ }
2532
+ function stop() {
2533
+ if (!isRunning)
2534
+ return;
2535
+ stopAnimation();
2536
+ term.fullscreen(false);
2537
+ term.grabInput(false);
2538
+ term.hideCursor(false);
2539
+ term.styleReset();
2540
+ // Print session IDs on exit
2541
+ console.log('\n\x1b[1mSession IDs:\x1b[0m');
2542
+ if (appState.arbiterSessionId) {
2543
+ console.log(` Arbiter: \x1b[33m${appState.arbiterSessionId}\x1b[0m`);
2544
+ }
2545
+ else {
2546
+ console.log(' Arbiter: \x1b[2m(no session)\x1b[0m');
2547
+ }
2548
+ if (appState.currentOrchestrator) {
2549
+ console.log(` Orchestrator: \x1b[36m${appState.currentOrchestrator.sessionId}\x1b[0m`);
2550
+ }
2551
+ console.log('');
2552
+ // Show crash count if any
2553
+ if (appState.crashCount > 0) {
2554
+ console.log(`\n\x1b[33m⚠ Session had ${appState.crashCount} crash${appState.crashCount > 1 ? 'es' : ''} (recovered)\x1b[0m`);
2555
+ }
2556
+ isRunning = false;
2557
+ }
2558
+ function onInput(callback) {
2559
+ inputCallback = callback;
2560
+ }
2561
+ function onExit(callback) {
2562
+ exitCallback = callback;
2563
+ }
2564
+ function onRequirementsReady(callback) {
2565
+ requirementsReadyCallback = callback;
2566
+ }
2567
+ function startWaiting(waitingFor) {
2568
+ // Ignore during entrance sequence
2569
+ if (!entranceComplete)
2570
+ return;
2571
+ state.waitingFor = waitingFor;
2572
+ // Hop for 3 seconds (6 hops at 500ms each)
2573
+ const target = waitingFor === 'arbiter' ? 'arbiter' : 'conjuring';
2574
+ triggerCharacterHop(target, 6);
2575
+ // Turn on bubbles
2576
+ state.sceneState.bubbleVisible = true;
2577
+ drawTiles(true);
2578
+ // Auto-scroll to show the working indicator using single source of truth
2579
+ const layout = getLayout(state.inputBuffer);
2580
+ const renderedLines = getRenderedChatLines(layout.chatArea.width);
2581
+ state.scrollOffset = Math.max(0, renderedLines.length - layout.chatArea.height);
2582
+ drawChat(true);
2583
+ }
2584
+ function stopWaiting() {
2585
+ state.waitingFor = 'none';
2586
+ // Clear any remaining hops and turn off bubbles
2587
+ clearAllHops();
2588
+ state.sceneState.bubbleVisible = false;
2589
+ drawTiles(true);
2590
+ drawChat(true); // Clear the working indicator
2591
+ }
2592
+ return {
2593
+ start,
2594
+ stop,
2595
+ getRouterCallbacks,
2596
+ onInput,
2597
+ onExit,
2598
+ onRequirementsReady,
2599
+ startWaiting,
2600
+ stopWaiting,
2601
+ };
2602
+ }