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.
- package/README.md +41 -0
- package/assets/jerom_16x16.png +0 -0
- package/dist/arbiter.d.ts +43 -0
- package/dist/arbiter.js +486 -0
- package/dist/context-analyzer.d.ts +15 -0
- package/dist/context-analyzer.js +603 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +165 -0
- package/dist/orchestrator.d.ts +31 -0
- package/dist/orchestrator.js +227 -0
- package/dist/router.d.ts +187 -0
- package/dist/router.js +1135 -0
- package/dist/router.test.d.ts +15 -0
- package/dist/router.test.js +95 -0
- package/dist/session-persistence.d.ts +9 -0
- package/dist/session-persistence.js +63 -0
- package/dist/session-persistence.test.d.ts +1 -0
- package/dist/session-persistence.test.js +165 -0
- package/dist/sound.d.ts +31 -0
- package/dist/sound.js +50 -0
- package/dist/state.d.ts +72 -0
- package/dist/state.js +107 -0
- package/dist/state.test.d.ts +1 -0
- package/dist/state.test.js +194 -0
- package/dist/test-headless.d.ts +1 -0
- package/dist/test-headless.js +155 -0
- package/dist/tui/index.d.ts +14 -0
- package/dist/tui/index.js +17 -0
- package/dist/tui/layout.d.ts +30 -0
- package/dist/tui/layout.js +200 -0
- package/dist/tui/render.d.ts +57 -0
- package/dist/tui/render.js +266 -0
- package/dist/tui/scene.d.ts +64 -0
- package/dist/tui/scene.js +366 -0
- package/dist/tui/screens/CharacterSelect-termkit.d.ts +18 -0
- package/dist/tui/screens/CharacterSelect-termkit.js +216 -0
- package/dist/tui/screens/ForestIntro-termkit.d.ts +15 -0
- package/dist/tui/screens/ForestIntro-termkit.js +856 -0
- package/dist/tui/screens/GitignoreCheck-termkit.d.ts +14 -0
- package/dist/tui/screens/GitignoreCheck-termkit.js +185 -0
- package/dist/tui/screens/TitleScreen-termkit.d.ts +14 -0
- package/dist/tui/screens/TitleScreen-termkit.js +132 -0
- package/dist/tui/screens/index.d.ts +9 -0
- package/dist/tui/screens/index.js +10 -0
- package/dist/tui/tileset.d.ts +97 -0
- package/dist/tui/tileset.js +237 -0
- package/dist/tui/tui-termkit.d.ts +34 -0
- package/dist/tui/tui-termkit.js +2602 -0
- package/dist/tui/types.d.ts +41 -0
- package/dist/tui/types.js +4 -0
- 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
|
+
}
|