centaurus-cli 2.8.9 → 2.9.1

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 (109) hide show
  1. package/dist/cli-adapter.d.ts +29 -2
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +526 -84
  4. package/dist/cli-adapter.js.map +1 -1
  5. package/dist/config/build-config.d.ts +1 -1
  6. package/dist/config/build-config.js +1 -1
  7. package/dist/config/models.d.ts +8 -0
  8. package/dist/config/models.d.ts.map +1 -1
  9. package/dist/config/models.js +29 -0
  10. package/dist/config/models.js.map +1 -1
  11. package/dist/config/slash-commands.d.ts +1 -0
  12. package/dist/config/slash-commands.d.ts.map +1 -1
  13. package/dist/config/slash-commands.js +14 -1
  14. package/dist/config/slash-commands.js.map +1 -1
  15. package/dist/hooks/useConnectivity.d.ts +2 -0
  16. package/dist/hooks/useConnectivity.d.ts.map +1 -0
  17. package/dist/hooks/useConnectivity.js +12 -0
  18. package/dist/hooks/useConnectivity.js.map +1 -0
  19. package/dist/index.js +9 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/mcp/mcp-command-handler.d.ts.map +1 -1
  22. package/dist/mcp/mcp-command-handler.js +0 -3
  23. package/dist/mcp/mcp-command-handler.js.map +1 -1
  24. package/dist/mcp/mcp-tool-wrapper.d.ts.map +1 -1
  25. package/dist/mcp/mcp-tool-wrapper.js +8 -0
  26. package/dist/mcp/mcp-tool-wrapper.js.map +1 -1
  27. package/dist/services/ai-service-client.d.ts +7 -1
  28. package/dist/services/ai-service-client.d.ts.map +1 -1
  29. package/dist/services/ai-service-client.js +6 -6
  30. package/dist/services/ai-service-client.js.map +1 -1
  31. package/dist/services/api-client.d.ts +35 -38
  32. package/dist/services/api-client.d.ts.map +1 -1
  33. package/dist/services/api-client.js +38 -30
  34. package/dist/services/api-client.js.map +1 -1
  35. package/dist/services/connectivity-manager.d.ts +18 -0
  36. package/dist/services/connectivity-manager.d.ts.map +1 -0
  37. package/dist/services/connectivity-manager.js +72 -0
  38. package/dist/services/connectivity-manager.js.map +1 -0
  39. package/dist/services/local-chat-storage.d.ts +5 -0
  40. package/dist/services/local-chat-storage.d.ts.map +1 -1
  41. package/dist/services/local-chat-storage.js +33 -0
  42. package/dist/services/local-chat-storage.js.map +1 -1
  43. package/dist/services/session-quota-manager.d.ts +101 -0
  44. package/dist/services/session-quota-manager.d.ts.map +1 -0
  45. package/dist/services/session-quota-manager.js +242 -0
  46. package/dist/services/session-quota-manager.js.map +1 -0
  47. package/dist/tools/background-command.d.ts +11 -0
  48. package/dist/tools/background-command.d.ts.map +1 -0
  49. package/dist/tools/background-command.js +162 -0
  50. package/dist/tools/background-command.js.map +1 -0
  51. package/dist/tools/command.d.ts.map +1 -1
  52. package/dist/tools/command.js +20 -6
  53. package/dist/tools/command.js.map +1 -1
  54. package/dist/tools/create-image.d.ts +10 -0
  55. package/dist/tools/create-image.d.ts.map +1 -0
  56. package/dist/tools/create-image.js +189 -0
  57. package/dist/tools/create-image.js.map +1 -0
  58. package/dist/tools/get-diff.d.ts.map +1 -1
  59. package/dist/tools/get-diff.js +4 -1
  60. package/dist/tools/get-diff.js.map +1 -1
  61. package/dist/tools/task-complete.d.ts.map +1 -1
  62. package/dist/tools/task-complete.js +8 -14
  63. package/dist/tools/task-complete.js.map +1 -1
  64. package/dist/ui/components/App.d.ts +5 -2
  65. package/dist/ui/components/App.d.ts.map +1 -1
  66. package/dist/ui/components/App.js +165 -45
  67. package/dist/ui/components/App.js.map +1 -1
  68. package/dist/ui/components/ContextWindowIndicator.d.ts.map +1 -1
  69. package/dist/ui/components/ContextWindowIndicator.js +43 -22
  70. package/dist/ui/components/ContextWindowIndicator.js.map +1 -1
  71. package/dist/ui/components/InputBox.d.ts +2 -0
  72. package/dist/ui/components/InputBox.d.ts.map +1 -1
  73. package/dist/ui/components/InputBox.js +217 -200
  74. package/dist/ui/components/InputBox.js.map +1 -1
  75. package/dist/ui/components/MessageDisplay.d.ts.map +1 -1
  76. package/dist/ui/components/MessageDisplay.js +8 -15
  77. package/dist/ui/components/MessageDisplay.js.map +1 -1
  78. package/dist/ui/components/SlashCommandAutocomplete.d.ts +2 -0
  79. package/dist/ui/components/SlashCommandAutocomplete.d.ts.map +1 -1
  80. package/dist/ui/components/SlashCommandAutocomplete.js +19 -10
  81. package/dist/ui/components/SlashCommandAutocomplete.js.map +1 -1
  82. package/dist/ui/components/StatusBar.d.ts.map +1 -1
  83. package/dist/ui/components/StatusBar.js +4 -0
  84. package/dist/ui/components/StatusBar.js.map +1 -1
  85. package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
  86. package/dist/ui/components/ToolExecutionMessage.js +198 -39
  87. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  88. package/dist/ui/components/ToolExecutionStatus.d.ts.map +1 -1
  89. package/dist/ui/components/ToolExecutionStatus.js +1 -0
  90. package/dist/ui/components/ToolExecutionStatus.js.map +1 -1
  91. package/dist/utils/chat-formatter.d.ts +12 -0
  92. package/dist/utils/chat-formatter.d.ts.map +1 -0
  93. package/dist/utils/chat-formatter.js +326 -0
  94. package/dist/utils/chat-formatter.js.map +1 -0
  95. package/dist/utils/editor-utils.d.ts +3 -3
  96. package/dist/utils/editor-utils.d.ts.map +1 -1
  97. package/dist/utils/editor-utils.js +15 -12
  98. package/dist/utils/editor-utils.js.map +1 -1
  99. package/dist/utils/input-classifier.d.ts.map +1 -1
  100. package/dist/utils/input-classifier.js +140 -20
  101. package/dist/utils/input-classifier.js.map +1 -1
  102. package/dist/utils/terminal-output.d.ts.map +1 -1
  103. package/dist/utils/terminal-output.js +198 -171
  104. package/dist/utils/terminal-output.js.map +1 -1
  105. package/dist/utils/text-clipboard.d.ts +12 -0
  106. package/dist/utils/text-clipboard.d.ts.map +1 -0
  107. package/dist/utils/text-clipboard.js +63 -0
  108. package/dist/utils/text-clipboard.js.map +1 -0
  109. package/package.json +1 -1
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
+ import { useConnectivity } from '../../hooks/useConnectivity.js';
5
6
  import { Breadcrumbs } from './Breadcrumbs.js';
6
7
  import { ContextWindowIndicator } from './ContextWindowIndicator.js';
7
8
  import { logDebug } from '../../utils/logger.js';
@@ -9,9 +10,9 @@ import { detectIntent } from '../../utils/input-classifier.js';
9
10
  import { CommandHistoryManager } from '../../utils/command-history.js';
10
11
  import { SlashCommandAutocomplete } from './SlashCommandAutocomplete.js';
11
12
  import { FileTagAutocomplete } from './FileTagAutocomplete.js';
12
- import { ClipboardImageAutocomplete } from './ClipboardImageAutocomplete.js';
13
13
  import { filterCommands } from '../../config/slash-commands.js';
14
14
  import { getClipboardImages } from '../../services/clipboard-service.js';
15
+ import { useTerminalDimensions, TERMINAL_HEIGHT_CONSTANTS } from '../../hooks/useTerminalDimensions.js';
15
16
  const getVisualLines = (text, width) => {
16
17
  const logicalLines = text.split('\n');
17
18
  const visualLines = [];
@@ -49,7 +50,7 @@ const getVisualLines = (text, width) => {
49
50
  });
50
51
  return visualLines;
51
52
  };
52
- export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...', autoAcceptMode, model, planMode = false, commandMode = false, backgroundMode = false, currentWorkingDirectory, commandHistory = [], onToggleAutoAccept, onToggleCommandMode, onToggleBackgroundMode, isActive = true, subshellContext, currentTokens = 0, maxTokens = 1000000, isShellRunning = false, backgroundTaskCount = 0, initialValue = '', onValueChange, onSetAutoModeSetup }) => {
53
+ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...', autoAcceptMode, model, planMode = false, commandMode = false, backgroundMode = false, currentWorkingDirectory, commandHistory = [], onToggleAutoAccept, onToggleCommandMode, onToggleBackgroundMode, isActive = true, subshellContext, currentTokens = 0, maxTokens = 1000000, isShellRunning = false, backgroundTaskCount = 0, initialValue = '', onValueChange, onSetAutoModeSetup, sessionQuotaExhausted = false, sessionQuotaTimeRemaining = '' }) => {
53
54
  // Use initialValue for first mount, but manage state internally after that
54
55
  const [value, setValueInternal] = useState(initialValue);
55
56
  const [cursorOffset, setCursorOffset] = useState(0);
@@ -58,6 +59,10 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
58
59
  const [historyIndex, setHistoryIndex] = useState(-1);
59
60
  const [tempValue, setTempValue] = useState('');
60
61
  const ignoreNextChangeRef = useRef(false);
62
+ // Refs to track current value and cursor for paste handling
63
+ // This prevents stale closure issues when Ink calls useInput multiple times during paste
64
+ const valueRef = useRef(initialValue);
65
+ const cursorOffsetRef = useRef(0);
61
66
  // Auto Mode State
62
67
  const [isAutoMode, setIsAutoMode] = useState(true);
63
68
  const [detectedIntent, setDetectedIntent] = useState('ai');
@@ -70,34 +75,40 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
70
75
  const [selection, setSelection] = useState(null);
71
76
  // Reject Flash State (turns border red when submission is blocked)
72
77
  const [rejectFlash, setRejectFlash] = useState(false);
78
+ // Session Quota Message State (shows quota exhausted message)
79
+ const [showQuotaMessage, setShowQuotaMessage] = useState(false);
73
80
  // Slash Command Autocomplete State
74
81
  const [slashAutocompleteVisible, setSlashAutocompleteVisible] = useState(false);
75
82
  const [slashAutocompleteCommands, setSlashAutocompleteCommands] = useState([]);
76
83
  const [slashAutocompleteSelectedIndex, setSlashAutocompleteSelectedIndex] = useState(0);
84
+ const [slashAutocompleteScrollOffset, setSlashAutocompleteScrollOffset] = useState(0);
85
+ // Connectivity State
86
+ const isConnected = useConnectivity();
87
+ // Terminal dimensions for height-aware autocomplete
88
+ const dimensions = useTerminalDimensions();
89
+ // Max 5 items, min 0 for very small terminals (use MIN_ROWS_FOR_STREAMING as threshold)
90
+ const slashMaxVisibleItems = dimensions.rows < TERMINAL_HEIGHT_CONSTANTS.MIN_ROWS_FOR_STREAMING
91
+ ? 0
92
+ : Math.min(5, Math.max(1, Math.floor((dimensions.rows - 20) / 3)));
77
93
  // File Tag Autocomplete State (@ symbol)
78
94
  const [fileTagAutocompleteVisible, setFileTagAutocompleteVisible] = useState(false);
79
95
  const [fileTagSuggestions, setFileTagSuggestions] = useState([]);
80
96
  const [fileTagSelectedIndex, setFileTagSelectedIndex] = useState(0);
81
97
  const [activeFileTagStart, setActiveFileTagStart] = useState(null);
82
98
  const [confirmedFileTags, setConfirmedFileTags] = useState([]);
83
- // Clipboard Image State (#image command)
84
- const [clipboardAutocompleteVisible, setClipboardAutocompleteVisible] = useState(false);
85
- const [clipboardImages, setClipboardImages] = useState([]);
86
- const [clipboardSelectedIndex, setClipboardSelectedIndex] = useState(0);
87
- const [clipboardLoading, setClipboardLoading] = useState(false);
88
- const [activeClipboardStart, setActiveClipboardStart] = useState(null);
99
+ // Clipboard Image State (Alt+V paste)
89
100
  const [confirmedClipboardImages, setConfirmedClipboardImages] = useState([]);
90
- // Track positions of validated #image commands (for pink highlighting)
91
- const [validatedImagePositions, setValidatedImagePositions] = useState([]);
92
101
  // Track visual line count to force re-renders when text wraps
93
102
  // This is necessary because Ink doesn't automatically update layout when text wraps
94
103
  const [visualLineCount, setVisualLineCount] = useState(1);
95
104
  // Configuration for scrolling
96
105
  const MAX_VISIBLE_LINES = 9;
97
- // Wrapper for setValue that also notifies parent of changes
106
+ // Wrapper for setValue that also notifies parent of changes and updates ref
98
107
  const setValue = React.useCallback((newValue) => {
99
108
  setValueInternal(prev => {
100
109
  const resolvedValue = typeof newValue === 'function' ? newValue(prev) : newValue;
110
+ // Update ref synchronously for paste handling
111
+ valueRef.current = resolvedValue;
101
112
  // Notify parent of value change for preservation across screen transitions
102
113
  if (onValueChange) {
103
114
  onValueChange(resolvedValue);
@@ -105,12 +116,29 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
105
116
  return resolvedValue;
106
117
  });
107
118
  }, [onValueChange]);
119
+ // Wrapper for setCursorOffset that also updates ref
120
+ const setCursorOffsetWithRef = React.useCallback((newOffset) => {
121
+ setCursorOffset(prev => {
122
+ const resolvedOffset = typeof newOffset === 'function' ? newOffset(prev) : newOffset;
123
+ // Update ref synchronously for paste handling
124
+ cursorOffsetRef.current = resolvedOffset;
125
+ return resolvedOffset;
126
+ });
127
+ }, []);
108
128
  // Initialize cursor position when initialValue is provided
109
129
  useEffect(() => {
110
130
  if (initialValue && initialValue.length > 0) {
111
131
  setCursorOffset(initialValue.length);
132
+ cursorOffsetRef.current = initialValue.length;
112
133
  }
113
134
  }, []); // Only run on mount
135
+ // Keep refs in sync with state (for cases where state is updated directly)
136
+ useEffect(() => {
137
+ valueRef.current = value;
138
+ }, [value]);
139
+ useEffect(() => {
140
+ cursorOffsetRef.current = cursorOffset;
141
+ }, [cursorOffset]);
114
142
  // Load history on mount
115
143
  useEffect(() => {
116
144
  CommandHistoryManager.getInstance().load();
@@ -289,127 +317,6 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
289
317
  setActiveFileTagStart(null);
290
318
  }
291
319
  }, [value, cursorOffset, commandMode, currentWorkingDirectory]);
292
- // Hash (#) symbol detection effect for clipboard image autocomplete
293
- // When user types # in Agent mode, check clipboard for images and show dropdown
294
- useEffect(() => {
295
- // Don't show in command mode
296
- if (commandMode) {
297
- setClipboardAutocompleteVisible(false);
298
- setActiveClipboardStart(null);
299
- return;
300
- }
301
- // Find if cursor is right after a # character (or typing after #)
302
- let hashPosition = -1;
303
- for (let i = cursorOffset - 1; i >= 0; i--) {
304
- const char = value[i];
305
- // Stop if we hit whitespace
306
- if (/[\s\n]/.test(char))
307
- break;
308
- if (char === '#') {
309
- hashPosition = i;
310
- break;
311
- }
312
- }
313
- // Only treat # as image trigger if it's at start of input OR preceded by whitespace
314
- if (hashPosition === -1 || (hashPosition > 0 && !/[\s\n]/.test(value[hashPosition - 1]))) {
315
- setClipboardAutocompleteVisible(false);
316
- setActiveClipboardStart(null);
317
- return;
318
- }
319
- // Extract what's typed after #
320
- const query = value.slice(hashPosition + 1, cursorOffset).toLowerCase();
321
- // If #image is fully typed (with space or at end), hide dropdown (validation will handle it)
322
- if (query === 'image' || query.startsWith('image ')) {
323
- setClipboardAutocompleteVisible(false);
324
- setActiveClipboardStart(null);
325
- return;
326
- }
327
- // If user has typed something that doesn't match "image" prefix, hide dropdown
328
- if (query.length > 0 && !'image'.startsWith(query)) {
329
- setClipboardAutocompleteVisible(false);
330
- setActiveClipboardStart(null);
331
- return;
332
- }
333
- // Show dropdown and check clipboard
334
- setActiveClipboardStart(hashPosition);
335
- setClipboardLoading(true);
336
- setClipboardAutocompleteVisible(true);
337
- // Check clipboard asynchronously
338
- const checkClipboard = async () => {
339
- try {
340
- const images = await getClipboardImages();
341
- setClipboardImages(images);
342
- setClipboardLoading(false);
343
- setClipboardSelectedIndex(0);
344
- }
345
- catch (error) {
346
- logDebug(`Failed to check clipboard: ${error instanceof Error ? error.message : 'Unknown error'}`);
347
- setClipboardImages([]);
348
- setClipboardLoading(false);
349
- }
350
- };
351
- checkClipboard();
352
- }, [value, cursorOffset, commandMode]);
353
- // #image command detection effect
354
- // When user types #image, check clipboard for images and validate
355
- useEffect(() => {
356
- // Don't check in command mode
357
- if (commandMode)
358
- return;
359
- // Find all #image occurrences in the text
360
- const regex = /#image\b/gi;
361
- const matches = [];
362
- let match;
363
- while ((match = regex.exec(value)) !== null) {
364
- matches.push({ start: match.index, end: match.index + match[0].length });
365
- }
366
- // If no #image in text, clear validated positions
367
- if (matches.length === 0) {
368
- if (validatedImagePositions.length > 0) {
369
- setValidatedImagePositions([]);
370
- setConfirmedClipboardImages([]);
371
- }
372
- return;
373
- }
374
- // Check for new #image occurrences that haven't been validated yet
375
- const unvalidatedMatches = matches.filter(m => !validatedImagePositions.some(v => v.start === m.start && v.end === m.end));
376
- if (unvalidatedMatches.length === 0)
377
- return;
378
- // Check clipboard and validate new #image occurrences
379
- const validateImages = async () => {
380
- logDebug(`Found ${unvalidatedMatches.length} new #image occurrences to validate`);
381
- try {
382
- const images = await getClipboardImages();
383
- logDebug(`Clipboard check returned ${images.length} images`);
384
- if (images.length > 0) {
385
- const image = images[0];
386
- logDebug(`Image found in clipboard: ${image.displayName}, ${image.sizeBytes} bytes`);
387
- // Add the clipboard image once for EACH #image occurrence
388
- // This allows multiple #image tags in the same prompt to each attach the clipboard image
389
- // Each #image tag represents an intent to include the image at that location
390
- setConfirmedClipboardImages(prev => {
391
- // Create a unique copy of the image for each #image occurrence
392
- const newImages = unvalidatedMatches.map((_, index) => ({
393
- ...image,
394
- // Give each a unique id so they're treated as separate entries for upload
395
- id: `${image.id}_${Date.now()}_${index}`
396
- }));
397
- return [...prev, ...newImages];
398
- });
399
- // Mark all unvalidated positions as validated
400
- setValidatedImagePositions(prev => [...prev, ...unvalidatedMatches]);
401
- logDebug(`Validated ${unvalidatedMatches.length} #image occurrences, added ${unvalidatedMatches.length} images to queue`);
402
- }
403
- else {
404
- logDebug('No image in clipboard - #image command not validated');
405
- }
406
- }
407
- catch (error) {
408
- logDebug(`Failed to check clipboard: ${error instanceof Error ? error.message : 'Unknown error'}`);
409
- }
410
- };
411
- validateImages();
412
- }, [value, commandMode]);
413
320
  const pushToUndoStack = () => {
414
321
  setUndoStack(prev => [...prev, { value, cursorOffset }]);
415
322
  setRedoStack([]); // Clear redo stack on new action
@@ -433,11 +340,21 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
433
340
  // Handle slash command autocomplete navigation
434
341
  if (slashAutocompleteVisible) {
435
342
  if (key.downArrow) {
436
- setSlashAutocompleteSelectedIndex(prev => Math.min(prev + 1, slashAutocompleteCommands.length - 1));
343
+ const newIndex = Math.min(slashAutocompleteSelectedIndex + 1, slashAutocompleteCommands.length - 1);
344
+ setSlashAutocompleteSelectedIndex(newIndex);
345
+ // Scroll down if selected is below visible window
346
+ if (newIndex >= slashAutocompleteScrollOffset + slashMaxVisibleItems) {
347
+ setSlashAutocompleteScrollOffset(newIndex - slashMaxVisibleItems + 1);
348
+ }
437
349
  return;
438
350
  }
439
351
  if (key.upArrow) {
440
- setSlashAutocompleteSelectedIndex(prev => Math.max(prev - 1, 0));
352
+ const newIndex = Math.max(slashAutocompleteSelectedIndex - 1, 0);
353
+ setSlashAutocompleteSelectedIndex(newIndex);
354
+ // Scroll up if selected is above visible window
355
+ if (newIndex < slashAutocompleteScrollOffset) {
356
+ setSlashAutocompleteScrollOffset(newIndex);
357
+ }
441
358
  return;
442
359
  }
443
360
  if (key.return && input.length <= 1 && !key.shift && !key.ctrl) {
@@ -474,6 +391,13 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
474
391
  setCursorOffset(newValue.length);
475
392
  setSlashAutocompleteVisible(false);
476
393
  }
394
+ else if (value.startsWith('/sync ')) {
395
+ // We're selecting a sync subcommand
396
+ const newValue = `/sync ${selected.name} `;
397
+ setValue(newValue);
398
+ setCursorOffset(newValue.length);
399
+ setSlashAutocompleteVisible(false);
400
+ }
477
401
  else {
478
402
  // Regular slash command, replace everything
479
403
  const newValue = `/${selected.name} `;
@@ -486,6 +410,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
486
410
  if (subcommandMatches.length > 0) {
487
411
  setSlashAutocompleteCommands(subcommandMatches);
488
412
  setSlashAutocompleteSelectedIndex(0);
413
+ setSlashAutocompleteScrollOffset(0);
489
414
  // Keep autocomplete visible for subcommands
490
415
  }
491
416
  else {
@@ -497,6 +422,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
497
422
  if (subcommandMatches.length > 0) {
498
423
  setSlashAutocompleteCommands(subcommandMatches);
499
424
  setSlashAutocompleteSelectedIndex(0);
425
+ setSlashAutocompleteScrollOffset(0);
500
426
  // Keep autocomplete visible for subcommands
501
427
  }
502
428
  else {
@@ -508,6 +434,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
508
434
  if (subcommandMatches.length > 0) {
509
435
  setSlashAutocompleteCommands(subcommandMatches);
510
436
  setSlashAutocompleteSelectedIndex(0);
437
+ setSlashAutocompleteScrollOffset(0);
511
438
  // Keep autocomplete visible for subcommands
512
439
  }
513
440
  else {
@@ -519,6 +446,19 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
519
446
  if (subcommandMatches.length > 0) {
520
447
  setSlashAutocompleteCommands(subcommandMatches);
521
448
  setSlashAutocompleteSelectedIndex(0);
449
+ setSlashAutocompleteScrollOffset(0);
450
+ // Keep autocomplete visible for subcommands
451
+ }
452
+ else {
453
+ setSlashAutocompleteVisible(false);
454
+ }
455
+ }
456
+ else if (selected.name === 'sync') {
457
+ const subcommandMatches = filterCommands('sync ');
458
+ if (subcommandMatches.length > 0) {
459
+ setSlashAutocompleteCommands(subcommandMatches);
460
+ setSlashAutocompleteSelectedIndex(0);
461
+ setSlashAutocompleteScrollOffset(0);
522
462
  // Keep autocomplete visible for subcommands
523
463
  }
524
464
  else {
@@ -609,54 +549,36 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
609
549
  return;
610
550
  }
611
551
  }
612
- // Handle clipboard image (#) autocomplete navigation
613
- if (clipboardAutocompleteVisible) {
614
- if (key.downArrow) {
615
- setClipboardSelectedIndex(prev => Math.min(prev + 1, Math.max(clipboardImages.length - 1, 0)));
616
- return;
617
- }
618
- if (key.upArrow) {
619
- setClipboardSelectedIndex(prev => Math.max(prev - 1, 0));
620
- return;
621
- }
622
- if (key.return && input.length <= 1 && !key.shift && !key.ctrl) {
623
- // Select the image option and autocomplete #image
624
- if (clipboardImages.length > 0 && activeClipboardStart !== null) {
625
- pushToUndoStack();
626
- // Replace # with #image (and add space after)
627
- const beforeHash = value.slice(0, activeClipboardStart);
628
- const afterCursor = value.slice(cursorOffset);
629
- const newValue = beforeHash + '#image ' + afterCursor;
630
- const newCursorPos = activeClipboardStart + 7; // length of "#image "
631
- setValue(newValue);
632
- setCursorOffset(newCursorPos);
633
- setClipboardAutocompleteVisible(false);
634
- setActiveClipboardStart(null);
552
+ // Alt+V: Paste image from clipboard
553
+ // Detect Alt+V on Windows/Linux (key.meta is often Alt on Windows in Ink)
554
+ const isAltV = (key.meta && input === 'v') || (input === '√'); // '√' is Alt+V on some systems
555
+ if (isAltV && !commandMode) {
556
+ // Check clipboard for images asynchronously
557
+ (async () => {
558
+ try {
559
+ const images = await getClipboardImages();
560
+ if (images.length > 0) {
561
+ const image = images[0];
562
+ logDebug(`Alt+V: Image found in clipboard: ${image.displayName}, ${image.sizeBytes} bytes`);
563
+ // Add image to confirmed list with unique ID
564
+ setConfirmedClipboardImages(prev => [
565
+ ...prev,
566
+ {
567
+ ...image,
568
+ id: `${image.id}_${Date.now()}`
569
+ }
570
+ ]);
571
+ }
572
+ else {
573
+ logDebug('Alt+V: No image in clipboard');
574
+ }
635
575
  }
636
- return;
637
- }
638
- if (key.escape) {
639
- setClipboardAutocompleteVisible(false);
640
- setActiveClipboardStart(null);
641
- return;
642
- }
643
- // Tab also selects
644
- if (key.tab && !key.shift) {
645
- if (clipboardImages.length > 0 && activeClipboardStart !== null) {
646
- pushToUndoStack();
647
- const beforeHash = value.slice(0, activeClipboardStart);
648
- const afterCursor = value.slice(cursorOffset);
649
- const newValue = beforeHash + '#image ' + afterCursor;
650
- const newCursorPos = activeClipboardStart + 7;
651
- setValue(newValue);
652
- setCursorOffset(newCursorPos);
653
- setClipboardAutocompleteVisible(false);
654
- setActiveClipboardStart(null);
576
+ catch (error) {
577
+ logDebug(`Alt+V: Failed to check clipboard: ${error instanceof Error ? error.message : 'Unknown error'}`);
655
578
  }
656
- return;
657
- }
579
+ })();
580
+ return;
658
581
  }
659
- // Clipboard image paste is handled via Ctrl+V below
660
582
  // DELETE WORD BACKWARDS - Check this FIRST before standard backspace/delete
661
583
  // Triggers on any of these conditions:
662
584
  // 1. Ctrl+W (char code 23) - Standard Unix terminal shortcut
@@ -690,6 +612,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
690
612
  setSlashAutocompleteCommands(matches);
691
613
  setSlashAutocompleteVisible(true);
692
614
  setSlashAutocompleteSelectedIndex(0);
615
+ setSlashAutocompleteScrollOffset(0);
693
616
  }
694
617
  else {
695
618
  setSlashAutocompleteVisible(false);
@@ -703,6 +626,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
703
626
  setSlashAutocompleteCommands(matches);
704
627
  setSlashAutocompleteVisible(true);
705
628
  setSlashAutocompleteSelectedIndex(0);
629
+ setSlashAutocompleteScrollOffset(0);
706
630
  }
707
631
  else {
708
632
  setSlashAutocompleteVisible(false);
@@ -716,6 +640,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
716
640
  setSlashAutocompleteCommands(matches);
717
641
  setSlashAutocompleteVisible(true);
718
642
  setSlashAutocompleteSelectedIndex(0);
643
+ setSlashAutocompleteScrollOffset(0);
719
644
  }
720
645
  else {
721
646
  setSlashAutocompleteVisible(false);
@@ -729,6 +654,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
729
654
  setSlashAutocompleteCommands(matches);
730
655
  setSlashAutocompleteVisible(true);
731
656
  setSlashAutocompleteSelectedIndex(0);
657
+ setSlashAutocompleteScrollOffset(0);
732
658
  }
733
659
  else {
734
660
  setSlashAutocompleteVisible(false);
@@ -742,6 +668,21 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
742
668
  setSlashAutocompleteCommands(matches);
743
669
  setSlashAutocompleteVisible(true);
744
670
  setSlashAutocompleteSelectedIndex(0);
671
+ setSlashAutocompleteScrollOffset(0);
672
+ }
673
+ else {
674
+ setSlashAutocompleteVisible(false);
675
+ }
676
+ }
677
+ else if (newValue.startsWith('/sync ')) {
678
+ // Sync subcommands
679
+ const fullQuery = newValue.slice(1);
680
+ const matches = filterCommands(fullQuery);
681
+ if (matches.length > 0) {
682
+ setSlashAutocompleteCommands(matches);
683
+ setSlashAutocompleteVisible(true);
684
+ setSlashAutocompleteSelectedIndex(0);
685
+ setSlashAutocompleteScrollOffset(0);
745
686
  }
746
687
  else {
747
688
  setSlashAutocompleteVisible(false);
@@ -813,7 +754,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
813
754
  setCursorOffset(value.length);
814
755
  return;
815
756
  }
816
- // Note: Clipboard images are handled via #image command detection effect
757
+ // Note: Clipboard images are handled via Alt+V keyboard shortcut
817
758
  // DELETE CHAR - Only runs if Delete Word did NOT trigger
818
759
  // Triggers on:
819
760
  // 1. Backspace or Delete key flag is present
@@ -853,6 +794,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
853
794
  setSlashAutocompleteCommands(matches);
854
795
  setSlashAutocompleteVisible(true);
855
796
  setSlashAutocompleteSelectedIndex(0);
797
+ setSlashAutocompleteScrollOffset(0);
856
798
  }
857
799
  else {
858
800
  setSlashAutocompleteVisible(false);
@@ -866,6 +808,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
866
808
  setSlashAutocompleteCommands(matches);
867
809
  setSlashAutocompleteVisible(true);
868
810
  setSlashAutocompleteSelectedIndex(0);
811
+ setSlashAutocompleteScrollOffset(0);
869
812
  }
870
813
  else {
871
814
  setSlashAutocompleteVisible(false);
@@ -879,6 +822,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
879
822
  setSlashAutocompleteCommands(matches);
880
823
  setSlashAutocompleteVisible(true);
881
824
  setSlashAutocompleteSelectedIndex(0);
825
+ setSlashAutocompleteScrollOffset(0);
882
826
  }
883
827
  else {
884
828
  setSlashAutocompleteVisible(false);
@@ -892,6 +836,35 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
892
836
  setSlashAutocompleteCommands(matches);
893
837
  setSlashAutocompleteVisible(true);
894
838
  setSlashAutocompleteSelectedIndex(0);
839
+ setSlashAutocompleteScrollOffset(0);
840
+ }
841
+ else {
842
+ setSlashAutocompleteVisible(false);
843
+ }
844
+ }
845
+ else if (newValue.startsWith('/background-task ') || newValue.startsWith('/bkg ') || newValue.startsWith('/bg-task ')) {
846
+ // Background-task subcommands
847
+ const fullQuery = newValue.slice(1);
848
+ const matches = filterCommands(fullQuery);
849
+ if (matches.length > 0) {
850
+ setSlashAutocompleteCommands(matches);
851
+ setSlashAutocompleteVisible(true);
852
+ setSlashAutocompleteSelectedIndex(0);
853
+ setSlashAutocompleteScrollOffset(0);
854
+ }
855
+ else {
856
+ setSlashAutocompleteVisible(false);
857
+ }
858
+ }
859
+ else if (newValue.startsWith('/sync ')) {
860
+ // Sync subcommands
861
+ const fullQuery = newValue.slice(1);
862
+ const matches = filterCommands(fullQuery);
863
+ if (matches.length > 0) {
864
+ setSlashAutocompleteCommands(matches);
865
+ setSlashAutocompleteVisible(true);
866
+ setSlashAutocompleteSelectedIndex(0);
867
+ setSlashAutocompleteScrollOffset(0);
895
868
  }
896
869
  else {
897
870
  setSlashAutocompleteVisible(false);
@@ -930,6 +903,28 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
930
903
  }
931
904
  else {
932
905
  // Enter: Submit
906
+ // Check if submission is allowed when disconnected
907
+ // Allowed: /exit command, OR Command Mode is active, OR detected intent is 'command'
908
+ const isExitCommand = value.trim() === '/exit';
909
+ const isCommandIntent = detectedIntent === 'command';
910
+ const isAllowedOffline = isConnected || isExitCommand || commandMode || isCommandIntent;
911
+ if (!isAllowedOffline) {
912
+ setRejectFlash(true);
913
+ setTimeout(() => setRejectFlash(false), 1000);
914
+ return;
915
+ }
916
+ // Check session quota (only for AI mode, not command mode or slash commands)
917
+ // Slash commands and terminal commands should always be allowed
918
+ const isSlashCommand = value.trim().startsWith('/');
919
+ if (!commandMode && !isCommandIntent && !isSlashCommand && sessionQuotaExhausted) {
920
+ setRejectFlash(true);
921
+ setShowQuotaMessage(true);
922
+ setTimeout(() => {
923
+ setRejectFlash(false);
924
+ setShowQuotaMessage(false);
925
+ }, 3000); // Show quota message for 3 seconds
926
+ return;
927
+ }
933
928
  handleSubmit();
934
929
  }
935
930
  return;
@@ -1125,21 +1120,28 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1125
1120
  pushToUndoStack();
1126
1121
  // Handle paste with newlines
1127
1122
  const cleanedInput = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1128
- let newValue = value;
1129
- let newOffset = cursorOffset;
1123
+ // Use refs to get the latest value and cursor position
1124
+ // This prevents stale closure issues when Ink calls useInput multiple times during paste
1125
+ const currentValue = valueRef.current;
1126
+ const currentCursorOffset = cursorOffsetRef.current;
1127
+ let newValue = currentValue;
1128
+ let newOffset = currentCursorOffset;
1130
1129
  if (selection) {
1131
1130
  const start = Math.min(selection.start, selection.end);
1132
1131
  const end = Math.max(selection.start, selection.end);
1133
- newValue = value.slice(0, start) + cleanedInput + value.slice(end);
1132
+ newValue = currentValue.slice(0, start) + cleanedInput + currentValue.slice(end);
1134
1133
  newOffset = start + cleanedInput.length;
1135
1134
  setSelection(null);
1136
1135
  }
1137
1136
  else {
1138
- newValue = value.slice(0, cursorOffset) + cleanedInput + value.slice(cursorOffset);
1139
- newOffset = cursorOffset + cleanedInput.length;
1137
+ newValue = currentValue.slice(0, currentCursorOffset) + cleanedInput + currentValue.slice(currentCursorOffset);
1138
+ newOffset = currentCursorOffset + cleanedInput.length;
1140
1139
  }
1140
+ // Update refs immediately for subsequent paste chunks
1141
+ valueRef.current = newValue;
1142
+ cursorOffsetRef.current = newOffset;
1141
1143
  setValue(newValue);
1142
- setCursorOffset(newOffset);
1144
+ setCursorOffsetWithRef(newOffset);
1143
1145
  // Reset history/completions
1144
1146
  setHistoryIndex(-1);
1145
1147
  setCompletions([]);
@@ -1152,6 +1154,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1152
1154
  setSlashAutocompleteCommands(matches);
1153
1155
  setSlashAutocompleteVisible(true);
1154
1156
  setSlashAutocompleteSelectedIndex(0);
1157
+ setSlashAutocompleteScrollOffset(0);
1155
1158
  }
1156
1159
  else {
1157
1160
  setSlashAutocompleteVisible(false);
@@ -1165,6 +1168,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1165
1168
  setSlashAutocompleteCommands(matches);
1166
1169
  setSlashAutocompleteVisible(true);
1167
1170
  setSlashAutocompleteSelectedIndex(0);
1171
+ setSlashAutocompleteScrollOffset(0);
1168
1172
  }
1169
1173
  else {
1170
1174
  setSlashAutocompleteVisible(false);
@@ -1178,6 +1182,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1178
1182
  setSlashAutocompleteCommands(matches);
1179
1183
  setSlashAutocompleteVisible(true);
1180
1184
  setSlashAutocompleteSelectedIndex(0);
1185
+ setSlashAutocompleteScrollOffset(0);
1181
1186
  }
1182
1187
  else {
1183
1188
  setSlashAutocompleteVisible(false);
@@ -1191,6 +1196,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1191
1196
  setSlashAutocompleteCommands(matches);
1192
1197
  setSlashAutocompleteVisible(true);
1193
1198
  setSlashAutocompleteSelectedIndex(0);
1199
+ setSlashAutocompleteScrollOffset(0);
1194
1200
  }
1195
1201
  else {
1196
1202
  setSlashAutocompleteVisible(false);
@@ -1204,6 +1210,21 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1204
1210
  setSlashAutocompleteCommands(matches);
1205
1211
  setSlashAutocompleteVisible(true);
1206
1212
  setSlashAutocompleteSelectedIndex(0);
1213
+ setSlashAutocompleteScrollOffset(0);
1214
+ }
1215
+ else {
1216
+ setSlashAutocompleteVisible(false);
1217
+ }
1218
+ }
1219
+ else if (newValue.startsWith('/sync ')) {
1220
+ // Sync subcommands (when user types "/sync ")
1221
+ const fullQuery = newValue.slice(1); // Remove leading "/", pass "sync <subquery>" to filterCommands
1222
+ const matches = filterCommands(fullQuery);
1223
+ if (matches.length > 0) {
1224
+ setSlashAutocompleteCommands(matches);
1225
+ setSlashAutocompleteVisible(true);
1226
+ setSlashAutocompleteSelectedIndex(0);
1227
+ setSlashAutocompleteScrollOffset(0);
1207
1228
  }
1208
1229
  else {
1209
1230
  setSlashAutocompleteVisible(false);
@@ -1453,17 +1474,6 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1453
1474
  if (isInConfirmedFileTag || isInActiveFileTag) {
1454
1475
  return React.createElement(Text, { key: charIdx, color: "#00ccff", bold: true }, char);
1455
1476
  }
1456
- // Check if this character is part of a validated #image command
1457
- let isInValidatedImage = false;
1458
- for (const pos of validatedImagePositions) {
1459
- if (absPos >= pos.start && absPos < pos.end) {
1460
- isInValidatedImage = true;
1461
- break;
1462
- }
1463
- }
1464
- if (isInValidatedImage) {
1465
- return React.createElement(Text, { key: charIdx, color: "#ff69b4", bold: true }, char);
1466
- }
1467
1477
  return React.createElement(Text, { key: charIdx }, char);
1468
1478
  });
1469
1479
  // Handle cursor at end of line
@@ -1501,6 +1511,14 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1501
1511
  React.createElement(Text, { color: "#00ccff", bold: true }, "Auto ["),
1502
1512
  detectedIntent === 'command' ? (React.createElement(Text, { color: "#00cc66", bold: true }, "Terminal")) : (React.createElement(Text, { color: "#00ccff" }, "Agent")),
1503
1513
  React.createElement(Text, { color: "#00ccff", bold: true }, "]"))) : backgroundMode ? (React.createElement(Text, { color: "#9966ff", bold: true }, "Background")) : commandMode ? (React.createElement(Text, { color: "#00cc66", bold: true }, "Terminal")) : planMode ? (React.createElement(Text, { color: "#ffaa00", bold: true }, "Plan")) : (React.createElement(Text, { color: "#00ccff" }, "Agent"))))),
1514
+ confirmedClipboardImages.length > 0 && (React.createElement(Box, { marginBottom: 1, flexDirection: "row", flexWrap: "wrap", gap: 1 }, confirmedClipboardImages.map((img, index) => (React.createElement(Box, { key: img.id, borderStyle: "round", borderColor: "#ff69b4", paddingX: 1 },
1515
+ React.createElement(Text, { color: "#ff69b4", bold: true },
1516
+ "image_",
1517
+ index + 1)))))),
1518
+ showQuotaMessage && (React.createElement(Box, { justifyContent: "flex-end", marginBottom: 1 },
1519
+ React.createElement(Text, { color: "#ff3366", bold: true },
1520
+ "Session quota reached. Try again in ",
1521
+ sessionQuotaTimeRemaining))),
1504
1522
  React.createElement(Box, { flexDirection: "row", width: "100%" },
1505
1523
  React.createElement(Text, { color: "#666666" }, "> "),
1506
1524
  renderInput()),
@@ -1515,8 +1533,7 @@ export const InputBox = React.memo(({ onSubmit, placeholder = 'Ask anything...',
1515
1533
  !commandMode && autoAcceptMode ? (React.createElement(Text, { color: "#00cc66", bold: true }, "[AUTO-ACCEPT: ON]")) : !commandMode ? (React.createElement(Text, { color: "#666666", dimColor: true }, "[AUTO-ACCEPT: OFF]")) : null,
1516
1534
  !commandMode && (React.createElement(Box, { marginLeft: 1 },
1517
1535
  React.createElement(ContextWindowIndicator, { currentTokens: currentTokens, maxTokens: maxTokens }))))),
1518
- slashAutocompleteVisible && (React.createElement(SlashCommandAutocomplete, { commands: slashAutocompleteCommands, selectedIndex: slashAutocompleteSelectedIndex })),
1519
- fileTagAutocompleteVisible && (React.createElement(FileTagAutocomplete, { files: fileTagSuggestions, selectedIndex: fileTagSelectedIndex })),
1520
- clipboardAutocompleteVisible && (React.createElement(ClipboardImageAutocomplete, { images: clipboardImages, selectedIndex: clipboardSelectedIndex, isLoading: clipboardLoading }))));
1536
+ slashAutocompleteVisible && slashMaxVisibleItems > 0 && (React.createElement(SlashCommandAutocomplete, { commands: slashAutocompleteCommands, selectedIndex: slashAutocompleteSelectedIndex, maxVisibleItems: slashMaxVisibleItems, scrollOffset: slashAutocompleteScrollOffset })),
1537
+ fileTagAutocompleteVisible && (React.createElement(FileTagAutocomplete, { files: fileTagSuggestions, selectedIndex: fileTagSelectedIndex }))));
1521
1538
  });
1522
1539
  //# sourceMappingURL=InputBox.js.map