rl-rockcli 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/commands/attach/basic-repl.js +212 -0
  2. package/commands/attach/cleanup-history.js +189 -0
  3. package/commands/attach/cleanup-manager.js +163 -0
  4. package/commands/attach/copy-ui/copyRepl.js +195 -0
  5. package/commands/attach/copy-ui/index.js +7 -0
  6. package/commands/attach/copy-ui/render/outputBlock.js +25 -0
  7. package/commands/attach/copy-ui/viewport/viewport.js +23 -0
  8. package/commands/attach/copy-ui/viewport/wheel.js +14 -0
  9. package/commands/attach/history-manager.js +507 -0
  10. package/commands/attach/history-session.js +48 -0
  11. package/commands/attach/ink-repl/InkREPL.js +1507 -0
  12. package/commands/attach/ink-repl/builtinCommands.js +1253 -0
  13. package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
  14. package/commands/attach/ink-repl/components/Console.js +191 -0
  15. package/commands/attach/ink-repl/components/DetailView.js +148 -0
  16. package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
  17. package/commands/attach/ink-repl/components/InputArea.js +125 -0
  18. package/commands/attach/ink-repl/components/InputLine.js +18 -0
  19. package/commands/attach/ink-repl/components/OutputArea.js +22 -0
  20. package/commands/attach/ink-repl/components/OutputItem.js +96 -0
  21. package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
  22. package/commands/attach/ink-repl/components/Spinner.js +79 -0
  23. package/commands/attach/ink-repl/components/StatusBar.js +106 -0
  24. package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
  25. package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
  26. package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
  27. package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
  28. package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
  29. package/commands/attach/ink-repl/hooks/useResources.js +132 -0
  30. package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
  31. package/commands/attach/ink-repl/index.js +112 -0
  32. package/commands/attach/ink-repl/package.json +3 -0
  33. package/commands/attach/ink-repl/replState.js +947 -0
  34. package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
  35. package/commands/attach/ink-repl/shortcuts/index.js +332 -0
  36. package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
  37. package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
  38. package/commands/attach/ink-repl/themes/index.js +4 -0
  39. package/commands/attach/ink-repl/themes/themeManager.js +45 -0
  40. package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
  41. package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
  42. package/commands/attach/ink-repl/utils/clipboard.js +50 -0
  43. package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
  44. package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
  45. package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
  46. package/commands/attach/ink-repl/utils/formatTime.js +12 -0
  47. package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
  48. package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
  49. package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
  50. package/commands/attach/ink-repl/utils/paramHint.js +60 -0
  51. package/commands/attach/ink-repl/utils/parseError.js +174 -0
  52. package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
  53. package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
  54. package/commands/attach/ink-repl/utils/replSelection.js +205 -0
  55. package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
  56. package/commands/attach/ink-repl/utils/textWrap.js +117 -0
  57. package/commands/attach/ink-repl/utils/truncate.js +115 -0
  58. package/commands/attach/opentui-repl/App.tsx +891 -0
  59. package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
  60. package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
  61. package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
  62. package/commands/attach/opentui-repl/components/Console.tsx +73 -0
  63. package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
  64. package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
  65. package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
  66. package/commands/attach/opentui-repl/components/Header.tsx +24 -0
  67. package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
  68. package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
  69. package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
  70. package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
  71. package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
  72. package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
  73. package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
  74. package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
  75. package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
  76. package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
  77. package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
  78. package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
  79. package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
  80. package/commands/attach/opentui-repl/index.js +99 -0
  81. package/commands/attach/opentui-repl/keybindings.ts +39 -0
  82. package/commands/attach/opentui-repl/package.json +3 -0
  83. package/commands/attach/opentui-repl/render.tsx +72 -0
  84. package/commands/attach/opentui-repl/tsconfig.json +12 -0
  85. package/commands/attach/repl.js +791 -0
  86. package/commands/attach/sandbox-id-resolver.js +56 -0
  87. package/commands/attach/session-manager.js +307 -0
  88. package/commands/attach/ui-mode.js +146 -0
  89. package/package.json +1 -1
@@ -0,0 +1,947 @@
1
+ /**
2
+ * REPL State Management
3
+ * Pure functions for state transitions
4
+ */
5
+
6
+ import i18n from '../../../utils/i18n.js';
7
+ import { processCarriageReturns } from './utils/truncate.js';
8
+ const { t } = i18n;
9
+
10
+ /**
11
+ * Create initial REPL state
12
+ */
13
+ export function createInitialState(overrides = {}) {
14
+ // Welcome message as initial output
15
+ const welcomeOutput = {
16
+ id: 'welcome',
17
+ command: null,
18
+ output: t('welcome.connected'),
19
+ exitCode: 0,
20
+ timestamp: new Date(),
21
+ truncated: false,
22
+ prompt: null,
23
+ isWelcome: true,
24
+ };
25
+
26
+ return {
27
+ viewMode: 'repl', // 'repl' | 'detail'
28
+ detailContent: null,
29
+
30
+ outputs: [welcomeOutput],
31
+ outputScrollOffset: 0, // DEPRECATED: Item-based scrolling
32
+ lineScrollOffset: 0, // Line-based scrolling: number of lines scrolled up from bottom
33
+
34
+ buffer: '',
35
+ cursorPosition: 0, // Cursor position in buffer (0 = start, buffer.length = end)
36
+ isExecuting: false,
37
+ executingCommand: '',
38
+ executionStartTime: null,
39
+ lastExecutionDuration: null, // in milliseconds
40
+
41
+ menuVisible: false,
42
+ menuItems: [],
43
+ selectedIndex: 0,
44
+ menuType: null, // 'slash' | 'path' | 'at' | null
45
+ atContext: null, // Context for @ completion (from parseAtContext)
46
+
47
+ historyIndex: -1,
48
+ commandHistory: [],
49
+ savedBuffer: '',
50
+
51
+ resources: null,
52
+ shellPrompt: '$ ',
53
+
54
+ // Console state
55
+ consoleVisible: false,
56
+ consoleLogs: [],
57
+ consoleScrollOffset: 0,
58
+ consoleSelectedIndex: -1, // -1 = no selection, 0+ = selected log index
59
+
60
+ // Exit confirmation state
61
+ exitPending: false,
62
+
63
+ // Mouse capture mode (SGR mouse reporting). When disabled in REPL view,
64
+ // terminals can use native text selection.
65
+ mouseCaptureEnabled: true,
66
+
67
+ // Paginated file viewing state (for large files)
68
+ paginatedFile: null, // { filePath, totalLines, loadedStartLine, loadedEndLine, isLoading }
69
+
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Determine whether InkREPL 应启用鼠标捕获模式
76
+ * 仅在非输入区域(detail view、console)可见时返回 true
77
+ * @param {Object} state
78
+ * @returns {boolean}
79
+ */
80
+ export function shouldEnableMouseMode(state) {
81
+ if (!state) return false;
82
+ // When disabled, allow terminal native selection everywhere.
83
+ if (state.mouseCaptureEnabled === false) {
84
+ return false;
85
+ }
86
+ // REPL mode: disable mouse capture to allow terminal native text selection
87
+ // This enables mouse selection and copy without any additional key presses
88
+ if (state.viewMode === 'repl') {
89
+ return false;
90
+ }
91
+ // Only enable mouse capture in detail view and console
92
+ if (state.viewMode === 'detail' && state.detailContent) {
93
+ return true;
94
+ }
95
+ if (state.consoleVisible) {
96
+ return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ /**
102
+ * Toggle mouse capture mode for the REPL.
103
+ * DEPRECATED: Mouse mode is now always enabled.
104
+ * @param {Object} state
105
+ * @returns {Object}
106
+ */
107
+ export function toggleMouseCapture(state) {
108
+ // Mouse mode is always enabled now, return state unchanged
109
+ return state;
110
+ }
111
+
112
+ /**
113
+ * Set exit pending state
114
+ */
115
+ export function setExitPending(state, pending) {
116
+ return { ...state, exitPending: pending };
117
+ }
118
+
119
+ /**
120
+ * Add command output to history
121
+ * @param {Object} state
122
+ * @param {string} command
123
+ * @param {string} output
124
+ * @param {number} exitCode
125
+ * @param {string} prompt - The prompt at the time of execution (optional, uses current if not provided)
126
+ * @param {string} metaInfo - Optional meta info (e.g., "[exit code: 1]")
127
+ * @param {string} tips - Optional tips for failed commands
128
+ */
129
+ export function addOutput(state, command, output, exitCode = 0, prompt = null, metaInfo = null, tips = null) {
130
+ // Process carriage returns to simulate terminal behavior
131
+ // This fixes issues with progress bars (e.g., apt-get update) that use \r
132
+ const processedOutput = processCarriageReturns(output);
133
+
134
+ const newOutput = {
135
+ id: `output-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
136
+ command,
137
+ output: processedOutput,
138
+ exitCode,
139
+ timestamp: Date.now(), // Unix timestamp in milliseconds
140
+ truncated: false,
141
+ prompt: prompt || state.shellPrompt, // Save the prompt at execution time
142
+ metaInfo,
143
+ tips,
144
+ };
145
+
146
+ // Always scroll to bottom when new output is added
147
+ // This ensures the new command output is always visible
148
+ const outputScrollOffset = 0;
149
+
150
+ return {
151
+ ...state,
152
+ outputs: [...state.outputs, newOutput],
153
+ outputScrollOffset,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Scroll REPL outputs (history) by N items.
159
+ * Offset is measured from the bottom: 0 = show latest, higher = show older.
160
+ *
161
+ * @param {Object} state
162
+ * @param {number} delta Positive = scroll up (older), negative = scroll down (newer)
163
+ * @param {number} maxVisibleOutputs How many output items can be shown at once
164
+ * @returns {Object} New state
165
+ */
166
+ /**
167
+ * Scroll outputs (支持小数 delta 实现平滑滚动)
168
+ * @param {Object} state
169
+ * @param {number} delta - 滚动增量,可以是小数(如 0.2)实现渐进式滚动
170
+ * @param {number} maxVisibleOutputs
171
+ */
172
+ /**
173
+ * Scroll outputs by number of lines (new line-based scrolling)
174
+ * @param {Object} state
175
+ * @param {number} deltaLines - Positive = scroll up (show older content), Negative = scroll down
176
+ * @param {number} totalVisibleLines - Total lines that can be displayed
177
+ * @returns {Object}
178
+ */
179
+ export function scrollOutputsByLines(state, deltaLines, totalVisibleLines) {
180
+ // Calculate total lines in all outputs
181
+ // This will be calculated by the rendering component and passed in
182
+ // For now, just accumulate the delta
183
+ const currentOffset = typeof state.lineScrollOffset === 'number' ? state.lineScrollOffset : 0;
184
+ const newOffset = Math.max(0, currentOffset + deltaLines);
185
+
186
+ return { ...state, lineScrollOffset: newOffset };
187
+ }
188
+
189
+ /**
190
+ * Scroll outputs by item offset (legacy item-based scrolling)
191
+ * @deprecated Use scrollOutputsByLines instead
192
+ */
193
+ export function scrollOutputs(state, delta, maxVisibleOutputs) {
194
+ const maxVisible = Math.max(1, Number(maxVisibleOutputs || 1));
195
+ const maxOffset = Math.max(0, (state.outputs?.length || 0) - maxVisible);
196
+
197
+ const current = typeof state.outputScrollOffset === 'number' ? state.outputScrollOffset : 0;
198
+ const next = Math.max(0, Math.min(maxOffset, current + delta));
199
+
200
+ if (next === current) return state;
201
+ return { ...state, outputScrollOffset: next };
202
+ }
203
+
204
+ /**
205
+ * Show menu with items
206
+ * @param {Object} state
207
+ * @param {Array} items
208
+ * @param {string} menuType - 'slash' | 'path' | 'at'
209
+ * @param {Object} atContext - Context for @ completion (optional)
210
+ */
211
+ export function showMenu(state, items, menuType = 'slash', atContext = null) {
212
+ return {
213
+ ...state,
214
+ menuVisible: true,
215
+ menuItems: items,
216
+ selectedIndex: 0,
217
+ menuType,
218
+ atContext: menuType === 'at' ? atContext : null,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Hide menu and reset selection
224
+ */
225
+ export function hideMenu(state) {
226
+ return {
227
+ ...state,
228
+ menuVisible: false,
229
+ menuItems: [],
230
+ selectedIndex: 0,
231
+ menuType: null,
232
+ atContext: null,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Select next menu item (cycle)
238
+ */
239
+ export function selectNext(state) {
240
+ if (state.menuItems.length === 0) return state;
241
+ return {
242
+ ...state,
243
+ selectedIndex: (state.selectedIndex + 1) % state.menuItems.length,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Select previous menu item (cycle)
249
+ */
250
+ export function selectPrev(state) {
251
+ if (state.menuItems.length === 0) return state;
252
+ return {
253
+ ...state,
254
+ selectedIndex: state.selectedIndex <= 0
255
+ ? state.menuItems.length - 1
256
+ : state.selectedIndex - 1,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Start command execution
262
+ */
263
+ export function startExecuting(state, command) {
264
+ return {
265
+ ...state,
266
+ isExecuting: true,
267
+ executingCommand: command,
268
+ executionStartTime: Date.now(),
269
+ buffer: '',
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Finish command execution
275
+ */
276
+ export function finishExecuting(state) {
277
+ const duration = state.executionStartTime
278
+ ? Date.now() - state.executionStartTime
279
+ : null;
280
+ return {
281
+ ...state,
282
+ isExecuting: false,
283
+ executingCommand: '',
284
+ executionStartTime: null,
285
+ lastExecutionDuration: duration,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Enter detail view mode
291
+ */
292
+ export function enterDetailView(state, title, content) {
293
+ return {
294
+ ...state,
295
+ viewMode: 'detail',
296
+ detailContent: { title, content, scrollOffset: 0, wrappedLines: null, wrapWidth: null, lineCount: null },
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Exit detail view mode
302
+ */
303
+ export function exitDetailView(state) {
304
+ return {
305
+ ...state,
306
+ viewMode: 'repl',
307
+ detailContent: null,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Navigate history up (older commands)
313
+ */
314
+ export function navigateHistoryUp(state) {
315
+ if (state.commandHistory.length === 0) return state;
316
+
317
+ if (state.historyIndex === -1) {
318
+ // First time navigating up, save current buffer
319
+ const newBuffer = state.commandHistory[state.commandHistory.length - 1];
320
+ return {
321
+ ...state,
322
+ savedBuffer: state.buffer,
323
+ historyIndex: state.commandHistory.length - 1,
324
+ buffer: newBuffer,
325
+ cursorPosition: newBuffer.length, // Move cursor to end
326
+ };
327
+ }
328
+
329
+ if (state.historyIndex > 0) {
330
+ const newIndex = state.historyIndex - 1;
331
+ const newBuffer = state.commandHistory[newIndex];
332
+ return {
333
+ ...state,
334
+ historyIndex: newIndex,
335
+ buffer: newBuffer,
336
+ cursorPosition: newBuffer.length, // Move cursor to end
337
+ };
338
+ }
339
+
340
+ return state; // at the beginning
341
+ }
342
+
343
+ /**
344
+ * Navigate history down (newer commands)
345
+ */
346
+ export function navigateHistoryDown(state) {
347
+ if (state.historyIndex === -1) return state;
348
+
349
+ if (state.historyIndex < state.commandHistory.length - 1) {
350
+ const newIndex = state.historyIndex + 1;
351
+ const newBuffer = state.commandHistory[newIndex];
352
+ return {
353
+ ...state,
354
+ historyIndex: newIndex,
355
+ buffer: newBuffer,
356
+ cursorPosition: newBuffer.length, // Move cursor to end
357
+ };
358
+ }
359
+
360
+ // Return to saved buffer
361
+ const restoredBuffer = state.savedBuffer;
362
+ return {
363
+ ...state,
364
+ historyIndex: -1,
365
+ buffer: restoredBuffer,
366
+ cursorPosition: restoredBuffer.length, // Move cursor to end
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Update buffer
372
+ */
373
+ export function setBuffer(state, buffer) {
374
+ return {
375
+ ...state,
376
+ buffer,
377
+ cursorPosition: buffer.length, // Move cursor to end when setting buffer
378
+ historyIndex: -1, // Reset history navigation when typing
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Move cursor left
384
+ */
385
+ export function moveCursorLeft(state) {
386
+ const newPos = Math.max(0, (state.cursorPosition || 0) - 1);
387
+ return { ...state, cursorPosition: newPos };
388
+ }
389
+
390
+ /**
391
+ * Move cursor right
392
+ */
393
+ export function moveCursorRight(state) {
394
+ const maxPos = state.buffer.length;
395
+ const newPos = Math.min(maxPos, (state.cursorPosition || 0) + 1);
396
+ return { ...state, cursorPosition: newPos };
397
+ }
398
+
399
+ /**
400
+ * Move cursor to start of line (Ctrl+A)
401
+ */
402
+ export function moveCursorToStart(state) {
403
+ return { ...state, cursorPosition: 0 };
404
+ }
405
+
406
+ /**
407
+ * Move cursor to end of line (Ctrl+E)
408
+ */
409
+ export function moveCursorToEnd(state) {
410
+ return { ...state, cursorPosition: state.buffer.length };
411
+ }
412
+
413
+ /**
414
+ * Update buffer and slash menu state together
415
+ * This prevents flickering by updating both in a single render
416
+ *
417
+ * @param {Object} state
418
+ * @param {string} buffer - New buffer value
419
+ * @param {Function} getSlashItems - Function that returns menu items for slash commands
420
+ */
421
+ export function setBufferWithSlashMenu(state, buffer, getSlashItems) {
422
+ const newState = {
423
+ ...state,
424
+ buffer,
425
+ cursorPosition: buffer.length, // Move cursor to end when setting buffer
426
+ historyIndex: -1,
427
+ };
428
+
429
+ // Only handle slash menu auto-show/hide (not path or @ completion)
430
+ if (state.isExecuting) {
431
+ return newState;
432
+ }
433
+
434
+ // Check if buffer starts with /
435
+ if (buffer.startsWith('/')) {
436
+ const items = getSlashItems(buffer);
437
+ if (items.length > 0) {
438
+ // Show slash menu
439
+ return {
440
+ ...newState,
441
+ menuVisible: true,
442
+ menuItems: items,
443
+ selectedIndex: state.menuType === 'slash' ? state.selectedIndex : 0,
444
+ menuType: 'slash',
445
+ };
446
+ } else if (state.menuType === 'slash') {
447
+ // Hide slash menu (no matches)
448
+ return {
449
+ ...newState,
450
+ menuVisible: false,
451
+ menuItems: [],
452
+ selectedIndex: 0,
453
+ menuType: null,
454
+ };
455
+ }
456
+ } else if (state.menuType === 'slash') {
457
+ // Buffer no longer starts with /, hide slash menu
458
+ return {
459
+ ...newState,
460
+ menuVisible: false,
461
+ menuItems: [],
462
+ selectedIndex: 0,
463
+ menuType: null,
464
+ };
465
+ }
466
+
467
+ return newState;
468
+ }
469
+
470
+ /**
471
+ * Add command to history
472
+ */
473
+ export function addToHistory(state, command) {
474
+ if (!command.trim()) return state;
475
+
476
+ // Avoid duplicates at end
477
+ const lastCommand = state.commandHistory[state.commandHistory.length - 1];
478
+ if (lastCommand === command) return state;
479
+
480
+ return {
481
+ ...state,
482
+ commandHistory: [...state.commandHistory, command],
483
+ historyIndex: -1,
484
+ savedBuffer: '',
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Update resources
490
+ */
491
+ export function updateResources(state, resources) {
492
+ return {
493
+ ...state,
494
+ resources,
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Update shell prompt
500
+ */
501
+ export function updatePrompt(state, prompt) {
502
+ return {
503
+ ...state,
504
+ shellPrompt: prompt,
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Scroll detail view
510
+ * @param {Object} state - Current state
511
+ * @param {number} delta - Scroll amount (positive = down, negative = up)
512
+ * @param {number} viewportHeight - Optional viewport height for max scroll calculation
513
+ */
514
+ export function scrollDetailView(state, delta, viewportHeight = 0) {
515
+ if (!state.detailContent) return state;
516
+
517
+ const lines = Number.isFinite(state.detailContent.lineCount)
518
+ ? state.detailContent.lineCount
519
+ : state.detailContent.content.split('\n').length;
520
+
521
+ // Calculate max scroll offset
522
+ // If viewport height is provided, limit so last page of content is visible
523
+ // Otherwise, allow scrolling to last line (legacy behavior)
524
+ let maxOffset;
525
+ if (viewportHeight > 0) {
526
+ // Content area height = viewportHeight - 2 (header)
527
+ const contentHeight = viewportHeight - 2;
528
+ maxOffset = Math.max(0, lines - contentHeight);
529
+ } else {
530
+ maxOffset = Math.max(0, lines - 1);
531
+ }
532
+
533
+ const newOffset = Math.max(0, Math.min(maxOffset, state.detailContent.scrollOffset + delta));
534
+
535
+ return {
536
+ ...state,
537
+ detailContent: {
538
+ ...state.detailContent,
539
+ scrollOffset: newOffset,
540
+ },
541
+ };
542
+ }
543
+
544
+ /**
545
+ * Toggle console visibility
546
+ */
547
+ export function toggleConsole(state) {
548
+ return {
549
+ ...state,
550
+ consoleVisible: !state.consoleVisible,
551
+ consoleScrollOffset: 0, // Reset scroll when toggling
552
+ };
553
+ }
554
+
555
+ /**
556
+ * Add log entry to console
557
+ * Auto-scrolls to bottom to show latest logs
558
+ */
559
+ export function addConsoleLog(state, level, message, consoleHeight = 7) {
560
+ const newLog = {
561
+ id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
562
+ level, // 'info' | 'warn' | 'error' | 'debug'
563
+ message,
564
+ timestamp: new Date(),
565
+ };
566
+
567
+ const newLogs = [...state.consoleLogs, newLog];
568
+ // Auto-scroll to bottom: offset = total logs - visible lines (keep latest visible)
569
+ const newScrollOffset = Math.max(0, newLogs.length - consoleHeight);
570
+
571
+ return {
572
+ ...state,
573
+ consoleLogs: newLogs,
574
+ consoleScrollOffset: newScrollOffset,
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Clear console logs
580
+ */
581
+ export function clearConsoleLogs(state) {
582
+ return {
583
+ ...state,
584
+ consoleLogs: [],
585
+ consoleScrollOffset: 0,
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Scroll console
591
+ */
592
+ export function scrollConsole(state, delta) {
593
+ const maxOffset = Math.max(0, state.consoleLogs.length - 6); // 6 visible lines
594
+ const newOffset = Math.max(0, Math.min(maxOffset, state.consoleScrollOffset + delta));
595
+
596
+ return {
597
+ ...state,
598
+ consoleScrollOffset: newOffset,
599
+ };
600
+ }
601
+
602
+ /**
603
+ * Select console log (up/down navigation)
604
+ */
605
+ export function selectConsoleLog(state, delta) {
606
+ if (state.consoleLogs.length === 0) return state;
607
+
608
+ let newIndex = state.consoleSelectedIndex + delta;
609
+
610
+ // Wrap around or clamp
611
+ if (newIndex < 0) newIndex = 0;
612
+ if (newIndex >= state.consoleLogs.length) newIndex = state.consoleLogs.length - 1;
613
+
614
+ // Auto-scroll to keep selection visible
615
+ const visibleHeight = 7;
616
+ let newOffset = state.consoleScrollOffset;
617
+
618
+ if (newIndex < newOffset) {
619
+ newOffset = newIndex;
620
+ } else if (newIndex >= newOffset + visibleHeight) {
621
+ newOffset = newIndex - visibleHeight + 1;
622
+ }
623
+
624
+ return {
625
+ ...state,
626
+ consoleSelectedIndex: newIndex,
627
+ consoleScrollOffset: newOffset,
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Get selected console log message
633
+ */
634
+ export function getSelectedConsoleLog(state) {
635
+ if (state.consoleSelectedIndex < 0 || state.consoleSelectedIndex >= state.consoleLogs.length) {
636
+ return null;
637
+ }
638
+ return state.consoleLogs[state.consoleSelectedIndex];
639
+ }
640
+
641
+ /**
642
+ * Clear console selection
643
+ */
644
+ export function clearConsoleSelection(state) {
645
+ return {
646
+ ...state,
647
+ consoleSelectedIndex: -1,
648
+ };
649
+ }
650
+
651
+ // ============================================
652
+ // Mouse Drag Selection State Management
653
+ // ============================================
654
+
655
+ /**
656
+ * Selection state structure:
657
+ * {
658
+ * isActive: boolean, // Whether selection is in progress
659
+ * startX: number, // Start column (1-based)
660
+ * startY: number, // Start row (1-based)
661
+ * endX: number, // End column (1-based)
662
+ * endY: number, // End row (1-based)
663
+ * content: string, // Selected text content
664
+ * source: 'detail' | 'console' | null, // Source of selection
665
+ * }
666
+ */
667
+
668
+ /**
669
+ * Start text selection
670
+ * @param {Object} state
671
+ * @param {number} x - Starting column (1-based)
672
+ * @param {number} y - Starting row (1-based)
673
+ * @param {string} source - Selection source ('detail' | 'console')
674
+ */
675
+ export function startSelection(state, x, y, source = null) {
676
+ return {
677
+ ...state,
678
+ selection: {
679
+ isActive: true,
680
+ startX: x,
681
+ startY: y,
682
+ endX: x,
683
+ endY: y,
684
+ content: '',
685
+ source,
686
+ },
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Update selection end position during drag
692
+ * @param {Object} state
693
+ * @param {number} x - Current column (1-based)
694
+ * @param {number} y - Current row (1-based)
695
+ * @param {Array<string>} lines - Text lines to extract content from
696
+ */
697
+ export function updateSelection(state, x, y, lines = []) {
698
+ if (!state.selection || !state.selection.isActive) return state;
699
+
700
+ const selection = {
701
+ ...state.selection,
702
+ endX: x,
703
+ endY: y,
704
+ };
705
+
706
+ // Calculate selected text content
707
+ selection.content = extractSelectedText(selection, lines);
708
+
709
+ return {
710
+ ...state,
711
+ selection,
712
+ };
713
+ }
714
+
715
+ /**
716
+ * End selection (mouse up)
717
+ * @param {Object} state
718
+ * @param {number} x - End column (1-based)
719
+ * @param {number} y - End row (1-based)
720
+ * @param {Array<string>} lines - Text lines to extract content from
721
+ */
722
+ export function endSelection(state, x, y, lines = []) {
723
+ if (!state.selection || !state.selection.isActive) return state;
724
+
725
+ const selection = {
726
+ ...state.selection,
727
+ isActive: false,
728
+ endX: x,
729
+ endY: y,
730
+ };
731
+
732
+ // Calculate final selected text content
733
+ selection.content = extractSelectedText(selection, lines);
734
+
735
+ return {
736
+ ...state,
737
+ selection,
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Clear selection
743
+ * @param {Object} state
744
+ */
745
+ export function clearSelection(state) {
746
+ return {
747
+ ...state,
748
+ selection: null,
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Get selected text content
754
+ * @param {Object} state
755
+ * @returns {string|null} Selected text or null if no selection
756
+ */
757
+ export function getSelectedText(state) {
758
+ return state.selection?.content || null;
759
+ }
760
+
761
+ /**
762
+ * Check if there is an active selection
763
+ * @param {Object} state
764
+ * @returns {boolean}
765
+ */
766
+ export function hasSelection(state) {
767
+ return !!state.selection && !!state.selection.content;
768
+ }
769
+
770
+ /**
771
+ * Extract selected text from lines based on selection coordinates
772
+ * @param {Object} selection - Selection state
773
+ * @param {Array<string>} lines - Array of text lines
774
+ * @returns {string} Selected text
775
+ */
776
+ function extractSelectedText(selection, lines) {
777
+ if (!lines || lines.length === 0) return '';
778
+
779
+ const { startX, startY, endX, endY } = normalizeSelection(selection);
780
+
781
+ // Convert 1-based coordinates to 0-based
782
+ const startRow = Math.max(0, startY - 1);
783
+ const endRow = Math.min(lines.length - 1, endY - 1);
784
+ const startCol = Math.max(0, startX - 1);
785
+ const endCol = Math.max(0, endX - 1);
786
+
787
+ if (startRow > endRow) return '';
788
+ if (startRow === endRow && startCol > endCol) return '';
789
+
790
+ const selectedLines = [];
791
+
792
+ for (let row = startRow; row <= endRow; row++) {
793
+ const line = lines[row] || '';
794
+
795
+ if (row === startRow && row === endRow) {
796
+ // Single line selection
797
+ selectedLines.push(line.substring(startCol, endCol));
798
+ } else if (row === startRow) {
799
+ // First line of multi-line selection
800
+ selectedLines.push(line.substring(startCol));
801
+ } else if (row === endRow) {
802
+ // Last line of multi-line selection
803
+ selectedLines.push(line.substring(0, endCol));
804
+ } else {
805
+ // Middle lines - select entire line
806
+ selectedLines.push(line);
807
+ }
808
+ }
809
+
810
+ return selectedLines.join('\n');
811
+ }
812
+
813
+ /**
814
+ * Normalize selection coordinates (ensure start <= end)
815
+ * @param {Object} selection - Selection state
816
+ * @returns {Object} Normalized coordinates
817
+ */
818
+ function normalizeSelection(selection) {
819
+ let { startX, startY, endX, endY } = selection;
820
+
821
+ // Swap if end is before start
822
+ if (startY > endY || (startY === endY && startX > endX)) {
823
+ [startX, endX] = [endX, startX];
824
+ [startY, endY] = [endY, startY];
825
+ }
826
+
827
+ return { startX, startY, endX, endY };
828
+ }
829
+
830
+ /**
831
+ * Check if a position is within the selection
832
+ * @param {Object} selection - Selection state
833
+ * @param {number} x - Column (1-based)
834
+ * @param {number} y - Row (1-based)
835
+ * @returns {boolean}
836
+ */
837
+ export function isPositionSelected(selection, x, y) {
838
+ if (!selection) return false;
839
+
840
+ const { startX, startY, endX, endY } = normalizeSelection(selection);
841
+
842
+ if (y < startY || y > endY) return false;
843
+ if (y === startY && y === endY) {
844
+ return x >= startX && x < endX;
845
+ }
846
+ if (y === startY) return x >= startX;
847
+ if (y === endY) return x < endX;
848
+ return true;
849
+ }
850
+
851
+ /**
852
+ * Check if a line has selection and return selection ranges
853
+ * @param {Object} selection - Selection state
854
+ * @param {number} lineIndex - 0-based line index
855
+ * @param {number} lineLength - Length of the line
856
+ * @returns {Array<{start: number, end: number}>} Selection ranges for this line
857
+ */
858
+ export function getLineSelectionRanges(selection, lineIndex, lineLength) {
859
+ if (!selection) return [];
860
+
861
+ const lineY = lineIndex + 1; // Convert to 1-based
862
+ const { startX, startY, endX, endY } = normalizeSelection(selection);
863
+
864
+ // Line is outside selection
865
+ if (lineY < startY || lineY > endY) return [];
866
+
867
+ const ranges = [];
868
+
869
+ if (startY === endY) {
870
+ // Single line selection
871
+ if (lineY === startY) {
872
+ ranges.push({
873
+ start: Math.max(0, startX - 1),
874
+ end: Math.min(lineLength, endX - 1),
875
+ });
876
+ }
877
+ } else if (lineY === startY) {
878
+ // First line of multi-line
879
+ ranges.push({
880
+ start: Math.max(0, startX - 1),
881
+ end: lineLength,
882
+ });
883
+ } else if (lineY === endY) {
884
+ // Last line of multi-line
885
+ ranges.push({
886
+ start: 0,
887
+ end: Math.min(lineLength, endX - 1),
888
+ });
889
+ } else {
890
+ // Middle line - entire line selected
891
+ ranges.push({
892
+ start: 0,
893
+ end: lineLength,
894
+ });
895
+ }
896
+
897
+ return ranges;
898
+ }
899
+
900
+ /**
901
+ * Set paginated file state (for large file viewing)
902
+ */
903
+ export function setPaginatedFile(state, fileInfo) {
904
+ return {
905
+ ...state,
906
+ paginatedFile: fileInfo,
907
+ };
908
+ }
909
+
910
+ /**
911
+ * Clear paginated file state
912
+ */
913
+ export function clearPaginatedFile(state) {
914
+ return {
915
+ ...state,
916
+ paginatedFile: null,
917
+ };
918
+ }
919
+
920
+ /**
921
+ * Update paginated file loading state
922
+ */
923
+ export function setPaginatedFileLoading(state, isLoading) {
924
+ if (!state.paginatedFile) return state;
925
+ return {
926
+ ...state,
927
+ paginatedFile: {
928
+ ...state.paginatedFile,
929
+ isLoading,
930
+ },
931
+ };
932
+ }
933
+
934
+ /**
935
+ * Extend paginated file loaded range (prepend or append content)
936
+ */
937
+ export function extendPaginatedFileRange(state, newStartLine, newEndLine) {
938
+ if (!state.paginatedFile) return state;
939
+ return {
940
+ ...state,
941
+ paginatedFile: {
942
+ ...state.paginatedFile,
943
+ loadedStartLine: Math.min(state.paginatedFile.loadedStartLine, newStartLine),
944
+ loadedEndLine: Math.max(state.paginatedFile.loadedEndLine, newEndLine),
945
+ },
946
+ };
947
+ }