centaurus-cli 2.9.4 → 2.9.6

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 (98) hide show
  1. package/dist/cli-adapter.d.ts +29 -4
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +700 -121
  4. package/dist/cli-adapter.js.map +1 -1
  5. package/dist/config/slash-commands.d.ts.map +1 -1
  6. package/dist/config/slash-commands.js +7 -0
  7. package/dist/config/slash-commands.js.map +1 -1
  8. package/dist/context/context-manager.d.ts +10 -0
  9. package/dist/context/context-manager.d.ts.map +1 -1
  10. package/dist/context/context-manager.js +17 -0
  11. package/dist/context/context-manager.js.map +1 -1
  12. package/dist/context/handlers/docker-handler.d.ts +7 -1
  13. package/dist/context/handlers/docker-handler.d.ts.map +1 -1
  14. package/dist/context/handlers/docker-handler.js +89 -16
  15. package/dist/context/handlers/docker-handler.js.map +1 -1
  16. package/dist/context/handlers/ssh-handler.d.ts +47 -1
  17. package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
  18. package/dist/context/handlers/ssh-handler.js +546 -73
  19. package/dist/context/handlers/ssh-handler.js.map +1 -1
  20. package/dist/context/handlers/wsl-handler.d.ts +5 -1
  21. package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
  22. package/dist/context/handlers/wsl-handler.js +24 -6
  23. package/dist/context/handlers/wsl-handler.js.map +1 -1
  24. package/dist/context/subshell-handler.d.ts +8 -2
  25. package/dist/context/subshell-handler.d.ts.map +1 -1
  26. package/dist/index.js +12 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/services/checkpoint-manager.d.ts +162 -0
  29. package/dist/services/checkpoint-manager.d.ts.map +1 -0
  30. package/dist/services/checkpoint-manager.js +926 -0
  31. package/dist/services/checkpoint-manager.js.map +1 -0
  32. package/dist/services/local-chat-storage.d.ts +3 -1
  33. package/dist/services/local-chat-storage.d.ts.map +1 -1
  34. package/dist/services/local-chat-storage.js +8 -3
  35. package/dist/services/local-chat-storage.js.map +1 -1
  36. package/dist/tools/background-command.d.ts.map +1 -1
  37. package/dist/tools/background-command.js +132 -24
  38. package/dist/tools/background-command.js.map +1 -1
  39. package/dist/tools/command.d.ts.map +1 -1
  40. package/dist/tools/command.js +106 -42
  41. package/dist/tools/command.js.map +1 -1
  42. package/dist/tools/create-image.d.ts.map +1 -1
  43. package/dist/tools/create-image.js +43 -18
  44. package/dist/tools/create-image.js.map +1 -1
  45. package/dist/tools/file-ops.d.ts.map +1 -1
  46. package/dist/tools/file-ops.js +12 -12
  47. package/dist/tools/file-ops.js.map +1 -1
  48. package/dist/tools/get-diff.d.ts +9 -45
  49. package/dist/tools/get-diff.d.ts.map +1 -1
  50. package/dist/tools/get-diff.js +288 -171
  51. package/dist/tools/get-diff.js.map +1 -1
  52. package/dist/tools/grep-search.d.ts +1 -1
  53. package/dist/tools/grep-search.d.ts.map +1 -1
  54. package/dist/tools/grep-search.js +80 -1
  55. package/dist/tools/grep-search.js.map +1 -1
  56. package/dist/tools/types.d.ts +3 -0
  57. package/dist/tools/types.d.ts.map +1 -1
  58. package/dist/ui/components/App.d.ts +8 -0
  59. package/dist/ui/components/App.d.ts.map +1 -1
  60. package/dist/ui/components/App.js +256 -66
  61. package/dist/ui/components/App.js.map +1 -1
  62. package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
  63. package/dist/ui/components/Breadcrumbs.js +22 -2
  64. package/dist/ui/components/Breadcrumbs.js.map +1 -1
  65. package/dist/ui/components/ConfirmPrompt.d.ts +2 -0
  66. package/dist/ui/components/ConfirmPrompt.d.ts.map +1 -1
  67. package/dist/ui/components/ConfirmPrompt.js +8 -3
  68. package/dist/ui/components/ConfirmPrompt.js.map +1 -1
  69. package/dist/ui/components/InputBox.d.ts +6 -0
  70. package/dist/ui/components/InputBox.d.ts.map +1 -1
  71. package/dist/ui/components/InputBox.js +188 -23
  72. package/dist/ui/components/InputBox.js.map +1 -1
  73. package/dist/ui/components/InteractiveShell.d.ts +2 -0
  74. package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
  75. package/dist/ui/components/InteractiveShell.js +88 -26
  76. package/dist/ui/components/InteractiveShell.js.map +1 -1
  77. package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
  78. package/dist/ui/components/KeyboardHelp.js +14 -6
  79. package/dist/ui/components/KeyboardHelp.js.map +1 -1
  80. package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
  81. package/dist/ui/components/ToolExecutionMessage.js +35 -16
  82. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  83. package/dist/utils/ansi-encoder.d.ts +5 -0
  84. package/dist/utils/ansi-encoder.d.ts.map +1 -1
  85. package/dist/utils/ansi-encoder.js +12 -5
  86. package/dist/utils/ansi-encoder.js.map +1 -1
  87. package/dist/utils/editor-utils.d.ts +14 -0
  88. package/dist/utils/editor-utils.d.ts.map +1 -1
  89. package/dist/utils/editor-utils.js +172 -0
  90. package/dist/utils/editor-utils.js.map +1 -1
  91. package/dist/utils/input-classifier.d.ts.map +1 -1
  92. package/dist/utils/input-classifier.js +2 -1
  93. package/dist/utils/input-classifier.js.map +1 -1
  94. package/dist/utils/terminal-output.d.ts +3 -1
  95. package/dist/utils/terminal-output.d.ts.map +1 -1
  96. package/dist/utils/terminal-output.js +235 -195
  97. package/dist/utils/terminal-output.js.map +1 -1
  98. package/package.json +3 -1
@@ -4,6 +4,7 @@ import SelectInput from 'ink-select-input';
4
4
  import { CircularSelectInput } from './CircularSelectInput.js';
5
5
  import stripAnsi from 'strip-ansi';
6
6
  import TextInput from 'ink-text-input';
7
+ import * as path from 'path';
7
8
  import { quickLog } from '../../utils/conversation-logger.js';
8
9
  import { WelcomeBanner } from './WelcomeBanner.js';
9
10
  import { InputBox } from './InputBox.js';
@@ -20,7 +21,7 @@ import { PasswordPrompt } from './PasswordPrompt.js';
20
21
  import { VersionUpdatePrompt } from './VersionUpdatePrompt.js';
21
22
  import { InteractiveShell } from './InteractiveShell.js';
22
23
  import { checkForUpdates } from '../../utils/version-checker.js';
23
- import { runInteractiveEditor, runWSLEditor, runDockerEditor, runSSHEditor } from '../../utils/editor-utils.js';
24
+ import { runInteractiveEditor, runWSLEditor, runDockerEditor, runSSHEditor, runNestedDockerSSHEditor } from '../../utils/editor-utils.js';
24
25
  import { DetailedPlanReviewScreen } from './DetailedPlanReviewScreen.js';
25
26
  import { TaskCompletedMessage } from './TaskCompletedMessage.js';
26
27
  import { PlanAcceptedMessage } from './PlanAcceptedMessage.js';
@@ -41,6 +42,26 @@ import { conversationManager } from '../../services/conversation-manager.js';
41
42
  import { logDebug, logError } from '../../utils/logger.js';
42
43
  import { getTerminalDimensions } from '../../hooks/useTerminalDimensions.js';
43
44
  import { ConfigManager } from '../../config/manager.js';
45
+ /**
46
+ * Check if a file path is outside the current working directory.
47
+ * Returns true if the path is NOT within the CWD or its subdirectories.
48
+ */
49
+ function isOutsideCwd(filePath, cwd) {
50
+ if (!filePath || !cwd)
51
+ return false;
52
+ try {
53
+ // Normalize paths for cross-platform comparison
54
+ // IMPORTANT: Resolve filePath against cwd to handle relative paths correctly
55
+ // This ensures remote paths (SSH/Docker) are resolved against their session CWD, not local process.cwd()
56
+ const normalizedFilePath = path.resolve(cwd, filePath).toLowerCase();
57
+ const normalizedCwd = path.resolve(cwd).toLowerCase();
58
+ // Check if the file path starts with the CWD (meaning it's inside or a subdirectory)
59
+ return !normalizedFilePath.startsWith(normalizedCwd);
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
44
65
  // Banner item with stable timestamp - created once outside component
45
66
  const BANNER_ITEM = { id: '__banner__', role: '__banner__', content: '', timestamp: new Date(0) };
46
67
  const MessageList = React.memo(({ history, current, showBanner }) => {
@@ -117,13 +138,18 @@ const ApprovalSection = React.memo(({ approvalRequest, onApprove }) => {
117
138
  // Memoize callbacks to prevent re-renders
118
139
  const handleYes = React.useCallback(() => onApprove(true), [onApprove]);
119
140
  const handleNo = React.useCallback(() => onApprove(false), [onApprove]);
141
+ // Generate warning message if operation is outside CWD
142
+ const warningMessage = approvalRequest.isOutsideCwd
143
+ ? 'This operation targets a location OUTSIDE your current working directory!'
144
+ : undefined;
120
145
  return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
121
146
  approvalRequest.preview && approvalRequest.preview.type === 'code' && (React.createElement(FileCreationPreview, { content: approvalRequest.preview.content, filePath: approvalRequest.operationDetails?.file_path || 'New File' })),
122
147
  approvalRequest.preview && approvalRequest.preview.type === 'diff' && (React.createElement(DiffViewer, { diff: approvalRequest.preview.content, filePath: approvalRequest.operationDetails?.file_path || 'Modified File', fullDiff: approvalRequest.preview.fullDiff })),
123
- React.createElement(ConfirmPrompt, { message: approvalRequest.message, onYes: handleYes, onNo: handleNo })));
148
+ React.createElement(ConfirmPrompt, { message: approvalRequest.message, onYes: handleYes, onNo: handleNo, warningMessage: warningMessage })));
124
149
  }, (prevProps, nextProps) => {
125
- // Only re-render if the approval request message changes
126
- return prevProps.approvalRequest.message === nextProps.approvalRequest.message;
150
+ // Only re-render if the approval request message or isOutsideCwd changes
151
+ return prevProps.approvalRequest.message === nextProps.approvalRequest.message &&
152
+ prevProps.approvalRequest.isOutsideCwd === nextProps.approvalRequest.isOutsideCwd;
127
153
  });
128
154
  // Simple rename input screen component
129
155
  const RenameInputScreen = ({ currentTitle, onRename, onCancel }) => {
@@ -149,11 +175,12 @@ const RenameInputScreen = ({ currentTitle, onRename, onCancel }) => {
149
175
  React.createElement(Box, { marginTop: 1 },
150
176
  React.createElement(Text, { dimColor: true }, "Press Enter to save, ESC to cancel"))));
151
177
  };
152
- export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode, onResponseReceived, onDirectMessage, onResponseStream, onClearStreamedResponse, onThoughtStream, onThoughtComplete, onPickerSetup, onPickerSelection, onToolExecutionUpdate, onToolApprovalRequest, onToolStreamingOutput, onPlanModeChange, onPlanApprovalRequest, onPlanCreated, onTaskCompleted, onCommandModeChange, onToggleCommandMode, onBackgroundModeChange, onToggleBackgroundMode, onBackgroundTaskCountChange, onSubAgentCountChange, onSetAutoModeSetup, onCwdChange, onModelChange, onSubshellContextChange, onPasswordRequest, onShellInput, onShellSignal, onKillProcess, onInteractiveEditorMode, onConnectionStatusUpdate, onTokenCountUpdate, onContextLimitReached, onChatPickerSetup, onChatPickerSelection, onChatDeletePickerSetup, onChatDeletePickerSelection, onChatListSetup, onChatRenamePickerSetup, onChatRename, onRestoreMessagesSetup, onUIMessageHistoryUpdate, onBackgroundTaskListSetup, onBackgroundTaskSelection, onBackgroundTaskCancelSetup, onBackgroundTaskCancel, onBackgroundTaskViewSetup, onSessionQuotaUpdate, onMCPAddScreenSetup, onMCPRemoveScreenSetup, onMCPEnableScreenSetup, onMCPDisableScreenSetup, onMCPListScreenSetup, onMCPAddServer, onMCPRemoveServer, onMCPEnableServer, onMCPDisableServer, onMCPValidateConfig, onPromptAnswered, getMainConversation, onWarpifySession, onWorkflowCreatorSetup, onWorkflowSave }) => {
178
+ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode, onResponseReceived, onDirectMessage, onResponseStream, onClearStreamedResponse, onThoughtStream, onThoughtComplete, onPickerSetup, onPickerSelection, onToolExecutionUpdate, onToolApprovalRequest, onToolStreamingOutput, onPlanModeChange, onPlanApprovalRequest, onPlanCreated, onTaskCompleted, onCommandModeChange, onToggleCommandMode, onBackgroundModeChange, onToggleBackgroundMode, onBackgroundTaskCountChange, onSubAgentCountChange, onSetAutoModeSetup, onCwdChange, onModelChange, onSubshellContextChange, onPasswordRequest, onShellInput, onShellSignal, onKillProcess, onInteractiveEditorMode, onConnectionStatusUpdate, onTokenCountUpdate, onContextLimitReached, onChatPickerSetup, onChatPickerSelection, onChatDeletePickerSetup, onChatDeletePickerSelection, onChatListSetup, onChatRenamePickerSetup, onChatRename, onRestoreMessagesSetup, onUIMessageHistoryUpdate, onBackgroundTaskListSetup, onBackgroundTaskSelection, onBackgroundTaskCancelSetup, onBackgroundTaskCancel, onBackgroundTaskViewSetup, onSessionQuotaUpdate, onMCPAddScreenSetup, onMCPRemoveScreenSetup, onMCPEnableScreenSetup, onMCPDisableScreenSetup, onMCPListScreenSetup, onMCPAddServer, onMCPRemoveServer, onMCPEnableServer, onMCPDisableServer, onMCPValidateConfig, onPromptAnswered, getMainConversation, onWarpifySession, onAiAutoSuggestChange, onWorkflowCreatorSetup, onWorkflowSave, getCheckpoints, onRevertToCheckpointSetup, onSetInputSetup }) => {
153
179
  const { exit } = useApp();
154
180
  // Calculate limit for paginated lists (75% of terminal height)
155
181
  const listLimit = Math.max(5, Math.floor((process.stdout.rows || 24) * 0.75));
156
182
  const autoAcceptRef = React.useRef(false);
183
+ const cwdRef = React.useRef(process.cwd()); // Track CWD for async callback access
157
184
  const setAutoModeCallbackRef = React.useRef(null);
158
185
  // Connectivity State
159
186
  const isConnected = useConnectivity();
@@ -367,6 +394,10 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
367
394
  React.useEffect(() => {
368
395
  autoAcceptRef.current = state.autoAcceptMode;
369
396
  }, [state.autoAcceptMode]);
397
+ // Keep CWD ref in sync with state
398
+ React.useEffect(() => {
399
+ cwdRef.current = state.currentWorkingDirectory;
400
+ }, [state.currentWorkingDirectory]);
370
401
  // Push shell output updates to InputDetectionAgent when agent control is active
371
402
  // This ensures the detection agent always has the latest output for polling
372
403
  React.useEffect(() => {
@@ -378,6 +409,9 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
378
409
  // This is set to true when an Agent-Controlled shell exits, essentially killing the "AI Agent"
379
410
  // associated with it from the UI perspective. It is reset when the user manually inputs a new message.
380
411
  const ignoreIncomingAIUpdatesRef = React.useRef(false);
412
+ // True only during Alt+E warpify transition, used to suppress expected shell termination noise.
413
+ const warpifyInProgressRef = React.useRef(false);
414
+ const warpifyTerminatingCommandRef = React.useRef(null);
381
415
  // NOTE: Token count is now updated via onTokenCountUpdate callback from cli-adapter
382
416
  // which tracks the actual AI conversation history including system prompt.
383
417
  // The old UI-based calculation has been removed to avoid overwriting correct values.
@@ -898,6 +932,14 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
898
932
  }));
899
933
  });
900
934
  }, []); // Empty dependency array - only register once
935
+ React.useEffect(() => {
936
+ onAiAutoSuggestChange((enabled) => {
937
+ setState(prev => ({
938
+ ...prev,
939
+ aiAutoSuggest: enabled
940
+ }));
941
+ });
942
+ }, [onAiAutoSuggestChange]);
901
943
  // Set up callback for setting Auto mode (used after background task starts)
902
944
  React.useEffect(() => {
903
945
  onSetAutoModeSetup((enabled) => {
@@ -910,14 +952,82 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
910
952
  // Set up callback for message restoration (when resuming a chat)
911
953
  React.useEffect(() => {
912
954
  onRestoreMessagesSetup((restoredMessages) => {
913
- // Clear the screen and restore messages
955
+ // Use two-phase restore to work around Ink's Static component caching:
956
+ // 1. First, clear messageHistory to unmount the Static component
957
+ // 2. Then after a short delay, set the restored messages
958
+ // This ensures Static re-renders fresh with the new message list
959
+ quickLog(`[${new Date().toISOString()}] [App] onRestoreMessagesSetup callback invoked with ${restoredMessages.length} messages\n`);
960
+ quickLog(`[${new Date().toISOString()}] [App] First message: ${restoredMessages[0]?.role} - "${restoredMessages[0]?.content?.substring(0, 50)}..."\n`);
961
+ quickLog(`[${new Date().toISOString()}] [App] Last message: ${restoredMessages[restoredMessages.length - 1]?.role} - "${restoredMessages[restoredMessages.length - 1]?.content?.substring(0, 50)}..."\n`);
962
+ // Phase 1: Clear screen and empty message history to unmount Static
914
963
  clearScreen();
915
964
  setState(prev => ({
916
965
  ...prev,
917
- messageHistory: restoredMessages,
918
- screen: 'chat',
966
+ messageHistory: [],
967
+ currentMessage: null,
968
+ screen: prev.screen === 'password-prompt' ? 'password-prompt' : 'chat',
919
969
  isLoading: false
920
970
  }));
971
+ quickLog(`[${new Date().toISOString()}] [App] Phase 1: Cleared messageHistory to unmount Static\n`);
972
+ // Phase 2: After brief delay, restore the messages
973
+ setTimeout(() => {
974
+ clearScreen();
975
+ setState(prev => ({
976
+ ...prev,
977
+ messageHistory: restoredMessages,
978
+ screen: prev.screen === 'password-prompt' ? 'password-prompt' : 'chat',
979
+ isLoading: false
980
+ }));
981
+ quickLog(`[${new Date().toISOString()}] [App] Phase 2: Restored ${restoredMessages.length} messages to messageHistory\n`);
982
+ }, 50); // Short delay to allow React to unmount and remount Static
983
+ });
984
+ }, []); // Empty dependency array - only register once
985
+ // Set up callback for revert to checkpoint (truncates UI message history)
986
+ React.useEffect(() => {
987
+ onRevertToCheckpointSetup((checkpointIndex, prompt) => {
988
+ setState(prev => {
989
+ let truncateAtIndex = -1;
990
+ // Message history length check for safety
991
+ if (typeof checkpointIndex === 'number' && checkpointIndex >= 0 && checkpointIndex < prev.messageHistory.length) {
992
+ // Use the explicit index provided by the checkpoint
993
+ truncateAtIndex = checkpointIndex;
994
+ quickLog(`[${new Date().toISOString()}] [App] Revert: using explicit checkpoint index ${checkpointIndex}\n`);
995
+ // Verify it matches the prompt (sanity check)
996
+ const targetMsg = prev.messageHistory[checkpointIndex];
997
+ if (targetMsg.role === 'user' && (!targetMsg.content || !targetMsg.content.includes(prompt.slice(0, 20)))) {
998
+ quickLog(`[${new Date().toISOString()}] [App] Revert WARNING: Message at index ${checkpointIndex} does not match prompt "${prompt.slice(0, 20)}..."\n`);
999
+ // We still use the index as it's likely more reliable than string matching for duplicates
1000
+ }
1001
+ }
1002
+ else {
1003
+ // Fallback: Find the user message via prompt matching
1004
+ // Search BACKWARDS to find the most recent occurrence (handling duplicates better)
1005
+ for (let i = prev.messageHistory.length - 1; i >= 0; i--) {
1006
+ const msg = prev.messageHistory[i];
1007
+ if (msg.role === 'user' && msg.content && msg.content.includes(prompt.slice(0, 50))) {
1008
+ truncateAtIndex = i;
1009
+ quickLog(`[${new Date().toISOString()}] [App] Revert: found message by prompt at index ${i}\n`);
1010
+ break;
1011
+ }
1012
+ }
1013
+ }
1014
+ if (truncateAtIndex >= 0) {
1015
+ // Truncate EXCLUSIVELY (remove the target message and everything after)
1016
+ // This allows the user to edit and resubmit the reverted message
1017
+ const truncatedHistory = prev.messageHistory.slice(0, truncateAtIndex);
1018
+ quickLog(`[${new Date().toISOString()}] [App] Reverted UI: truncated message history from ${prev.messageHistory.length} to ${truncatedHistory.length} messages (exclusive)\n`);
1019
+ return {
1020
+ ...prev,
1021
+ messageHistory: truncatedHistory,
1022
+ currentMessage: null, // Clear any current message
1023
+ isLoading: false,
1024
+ isAiWorking: false
1025
+ };
1026
+ }
1027
+ // If we couldn't find the message, log but don't crash
1028
+ quickLog(`[${new Date().toISOString()}] [App] Revert: could not find message matching prompt "${prompt.slice(0, 50)}"\n`);
1029
+ return prev;
1030
+ });
921
1031
  });
922
1032
  }, []); // Empty dependency array - only register once
923
1033
  // Set up callback for background task list picker
@@ -1202,6 +1312,23 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1202
1312
  }
1203
1313
  // COMPLETION / ERROR
1204
1314
  if (update.status === 'completed' || update.status === 'error') {
1315
+ const updateCommand = update.arguments?.command || update.arguments?.CommandLine || update.arguments?.commandLine || '';
1316
+ const terminatingCommand = warpifyTerminatingCommandRef.current || '';
1317
+ const isWarpifyTerminationUpdate = !terminatingCommand || !updateCommand || updateCommand === terminatingCommand;
1318
+ // During warpify, the PTY shell is intentionally terminated (SIGTERM).
1319
+ // Ignore the resulting execute_command completion/error update so we don't
1320
+ // show a false red shell card that can replace the tunneling status message.
1321
+ if (warpifyInProgressRef.current && isWarpifyTerminationUpdate) {
1322
+ if (pendingShellRef.current) {
1323
+ clearTimeout(pendingShellRef.current.timeoutId);
1324
+ pendingShellRef.current = null;
1325
+ }
1326
+ try {
1327
+ quickLog(`[${new Date().toISOString()}] [App] Suppressed execute_command ${update.status} during warpify transition\n`);
1328
+ }
1329
+ catch (e) { }
1330
+ return;
1331
+ }
1205
1332
  // Check if we have a pending shell (fast command)
1206
1333
  if (pendingShellRef.current) {
1207
1334
  // Command finished VERY fast (within 50ms)
@@ -1505,8 +1632,31 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1505
1632
  // Set up callback for tool approval requests
1506
1633
  React.useEffect(() => {
1507
1634
  onToolApprovalRequest(async (request) => {
1508
- // Auto-approve if auto-accept mode is on
1509
- if (autoAcceptRef.current) {
1635
+ // Determine if operation targets a path outside CWD
1636
+ let operationOutsideCwd = false;
1637
+ const currentCwd = cwdRef.current;
1638
+ if (currentCwd) {
1639
+ if (request.operationType === 'execute_command') {
1640
+ // For commands, check the Cwd parameter
1641
+ const commandCwd = request.operationDetails?.Cwd;
1642
+ if (commandCwd) {
1643
+ operationOutsideCwd = isOutsideCwd(commandCwd, currentCwd);
1644
+ }
1645
+ }
1646
+ else if (request.operationType === 'write_file' ||
1647
+ request.operationType === 'write_to_file' ||
1648
+ request.operationType === 'edit_file' ||
1649
+ request.operationType === 'multi_edit_file') {
1650
+ // For file operations, check the file_path
1651
+ const filePath = request.operationDetails?.file_path;
1652
+ if (filePath) {
1653
+ operationOutsideCwd = isOutsideCwd(filePath, currentCwd);
1654
+ }
1655
+ }
1656
+ }
1657
+ // Auto-approve if auto-accept mode is on AND operation is NOT outside CWD
1658
+ // Operations outside CWD always require explicit user approval for safety
1659
+ if (autoAcceptRef.current && !operationOutsideCwd) {
1510
1660
  return true;
1511
1661
  }
1512
1662
  // Check if this is a file operation with preview (write_file, write_to_file, edit_file, or multi_edit_file)
@@ -1527,6 +1677,7 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1527
1677
  preview: request.preview,
1528
1678
  operationType: request.operationType,
1529
1679
  operationDetails: request.operationDetails,
1680
+ isOutsideCwd: operationOutsideCwd,
1530
1681
  resolve: (approved) => {
1531
1682
  // First unmount the approval section to stop rendering
1532
1683
  setState(prev => ({
@@ -1560,6 +1711,7 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1560
1711
  preview: request.preview,
1561
1712
  operationType: request.operationType,
1562
1713
  operationDetails: request.operationDetails,
1714
+ isOutsideCwd: operationOutsideCwd,
1563
1715
  resolve: (approved) => {
1564
1716
  // Clear approval request
1565
1717
  setState(prev => ({
@@ -1837,23 +1989,25 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1837
1989
  return new Promise((resolve, reject) => {
1838
1990
  setState(prev => ({
1839
1991
  ...prev,
1840
- screen: 'password-prompt',
1992
+ // Keep chat screen when possible so connection/tunneling status remains visible.
1993
+ // Use dedicated password screen only if we're currently in another modal screen.
1994
+ screen: prev.screen === 'chat' ? 'chat' : 'password-prompt',
1841
1995
  passwordRequest: {
1842
1996
  message,
1843
1997
  resolve: (password) => {
1844
- // Return to chat screen
1998
+ // Return to chat screen if we were in dedicated password screen mode
1845
1999
  setState(prev2 => ({
1846
2000
  ...prev2,
1847
- screen: 'chat',
2001
+ screen: prev2.screen === 'password-prompt' ? 'chat' : prev2.screen,
1848
2002
  passwordRequest: undefined
1849
2003
  }));
1850
2004
  resolve(password);
1851
2005
  },
1852
2006
  reject: () => {
1853
- // Return to chat screen
2007
+ // Return to chat screen if we were in dedicated password screen mode
1854
2008
  setState(prev2 => ({
1855
2009
  ...prev2,
1856
- screen: 'chat',
2010
+ screen: prev2.screen === 'password-prompt' ? 'chat' : prev2.screen,
1857
2011
  passwordRequest: undefined
1858
2012
  }));
1859
2013
  reject(new Error('Password input cancelled'));
@@ -1868,7 +2022,7 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1868
2022
  // Ref to hold the current editor process for cleanup
1869
2023
  const editorProcessRef = React.useRef(null);
1870
2024
  React.useEffect(() => {
1871
- onInteractiveEditorMode((active, command, cwd, remoteContext) => {
2025
+ onInteractiveEditorMode((active, command, cwd, remoteContext, parentContext) => {
1872
2026
  if (active && command && cwd) {
1873
2027
  // Enter interactive editor mode
1874
2028
  setState(prev => ({ ...prev, isInteractiveEditorMode: true }));
@@ -1893,9 +2047,23 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
1893
2047
  editorProcessRef.current = runWSLEditor(distribution, command, cwd, handleEditorExit);
1894
2048
  }
1895
2049
  else if (remoteContext.type === 'docker') {
1896
- // Docker context: use Docker editor with container ID
2050
+ // Docker context: check if nested inside SSH or local
1897
2051
  const containerId = remoteContext.metadata?.containerId || '';
1898
- editorProcessRef.current = runDockerEditor(containerId, command, cwd, handleEditorExit);
2052
+ if (parentContext && parentContext.type === 'ssh') {
2053
+ // Nested Docker inside SSH: use SSH-tunneled editor
2054
+ const sshClient = parentContext.handler?.client;
2055
+ if (sshClient) {
2056
+ editorProcessRef.current = runNestedDockerSSHEditor(sshClient, containerId, command, cwd, handleEditorExit);
2057
+ }
2058
+ else {
2059
+ logError('SSH client not available for nested Docker editor', new Error('No SSH client'));
2060
+ setState(prev => ({ ...prev, isInteractiveEditorMode: false }));
2061
+ }
2062
+ }
2063
+ else {
2064
+ // Local Docker: use standard interactive editor
2065
+ editorProcessRef.current = runDockerEditor(containerId, command, cwd, handleEditorExit);
2066
+ }
1899
2067
  }
1900
2068
  else if (remoteContext.type === 'ssh') {
1901
2069
  // SSH context: use SSH editor with client
@@ -2499,54 +2667,66 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
2499
2667
  // Remote session detected - warpify it!
2500
2668
  const description = getSessionDescription(session);
2501
2669
  quickLog(`[${new Date().toISOString()}] [Warpify] Warpifying: ${description}\n`);
2502
- // Terminate the PTY session (we'll use ssh2 handler instead)
2503
- onShellSignal('SIGTERM');
2504
- // Capture history commands
2505
- const historyMessages = [];
2506
- // 1. Add the command the user typed (e.g., "ssh user@host")
2507
- if (shellCommand) {
2508
- historyMessages.push({
2509
- id: `warpify-cmd-${Date.now()}`,
2510
- role: 'user',
2511
- content: shellCommand,
2512
- timestamp: new Date(),
2513
- isCommandMode: false // Treat as a normal chat message for history
2514
- });
2515
- }
2516
- // 2. Add the terminal output (e.g., login banner, password prompt output)
2517
- if (currentOutput && currentOutput.trim().length > 0) {
2518
- historyMessages.push({
2519
- id: `warpify-out-${Date.now()}`,
2520
- role: 'tool', // Use 'tool' role to mimic command execution output
2521
- content: '',
2522
- timestamp: new Date(),
2523
- toolExecution: {
2524
- toolName: 'execute_command',
2525
- status: 'completed',
2526
- result: currentOutput, // This contains the PTY output including ANSI codes
2527
- arguments: {
2528
- command: shellCommand,
2529
- isPty: true
2670
+ warpifyInProgressRef.current = true;
2671
+ warpifyTerminatingCommandRef.current = shellCommand;
2672
+ try {
2673
+ // Terminate the PTY session (we'll use ssh2 handler instead)
2674
+ onShellSignal('SIGTERM');
2675
+ // Capture history commands
2676
+ const historyMessages = [];
2677
+ // 1. Add the command the user typed (e.g., "ssh user@host")
2678
+ if (shellCommand) {
2679
+ historyMessages.push({
2680
+ id: `warpify-cmd-${Date.now()}`,
2681
+ role: 'user',
2682
+ content: shellCommand,
2683
+ timestamp: new Date(),
2684
+ isCommandMode: false // Treat as a normal chat message for history
2685
+ });
2686
+ }
2687
+ // 2. Add the terminal output (e.g., login banner, password prompt output)
2688
+ if (currentOutput && currentOutput.trim().length > 0) {
2689
+ historyMessages.push({
2690
+ id: `warpify-out-${Date.now()}`,
2691
+ role: 'tool', // Use 'tool' role to mimic command execution output
2692
+ content: '',
2693
+ timestamp: new Date(),
2694
+ toolExecution: {
2695
+ toolName: 'execute_command',
2696
+ status: 'completed',
2697
+ result: currentOutput, // This contains the PTY output including ANSI codes
2698
+ arguments: {
2699
+ command: shellCommand,
2700
+ isPty: true
2701
+ }
2530
2702
  }
2531
- }
2532
- });
2703
+ });
2704
+ }
2705
+ // Clear shell-related UI state and append captured history
2706
+ // Reset isAiWorking to prevent "Boosting..." spinner from appearing
2707
+ setState(prev => ({
2708
+ ...prev,
2709
+ messageHistory: [...prev.messageHistory, ...historyMessages],
2710
+ shellState: undefined,
2711
+ currentMessage: null,
2712
+ isAiWorking: false
2713
+ }));
2714
+ // Clear screen
2715
+ clearScreen();
2716
+ // Establish proper ssh2 connection via cli-adapter
2717
+ // This will prompt for password and set up full SSH functionality
2718
+ const success = await onWarpifySession(shellCommand, session.type, session.connectionString);
2719
+ if (!success) {
2720
+ // Connection failed - error already shown by cli.warpifySession
2721
+ quickLog(`[${new Date().toISOString()}] [Warpify] Failed to establish ssh2 connection\n`);
2722
+ }
2533
2723
  }
2534
- // Clear the shell state AND append the history messages
2535
- // Also reset isAiWorking to prevent "Boosting..." spinner from appearing
2536
- setState(prev => ({
2537
- ...prev,
2538
- messageHistory: [...prev.messageHistory, ...historyMessages],
2539
- shellState: undefined,
2540
- isAiWorking: false
2541
- }));
2542
- // Clear screen
2543
- clearScreen();
2544
- // Establish proper ssh2 connection via cli-adapter
2545
- // This will prompt for password and set up full SSH functionality
2546
- const success = await onWarpifySession(shellCommand, session.type, session.connectionString);
2547
- if (!success) {
2548
- // Connection failed - error already shown by cli.warpifySession
2549
- quickLog(`[${new Date().toISOString()}] [Warpify] Failed to establish ssh2 connection\n`);
2724
+ finally {
2725
+ // Keep suppression active briefly so delayed shell completion events are ignored.
2726
+ setTimeout(() => {
2727
+ warpifyInProgressRef.current = false;
2728
+ warpifyTerminatingCommandRef.current = null;
2729
+ }, 400);
2550
2730
  }
2551
2731
  } })),
2552
2732
  state.shellState?.isAgentControlled && state.shellState?.isFocused && state.shellState?.isRunning && (React.createElement(MonitorModeAIPanel, { messages: [
@@ -2563,7 +2743,17 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
2563
2743
  !(state.currentMessage?.toolExecution?.status === 'executing') && (React.createElement(Box, { marginBottom: 1, paddingLeft: 1 },
2564
2744
  React.createElement(LoadingIndicator, { key: "loading-indicator" }),
2565
2745
  React.createElement(AgentTimer, { key: "agent-timer" }))),
2566
- !state.shellState?.isFocused && (state.approvalRequest ? (React.createElement(ApprovalSection, { key: `approval-${state.approvalRequest.message}`, approvalRequest: state.approvalRequest, onApprove: (approved) => {
2746
+ !state.shellState?.isFocused && (state.passwordRequest ? (React.createElement(PasswordPrompt, { message: state.passwordRequest.message, onSubmit: (password) => {
2747
+ const resolve = state.passwordRequest?.resolve;
2748
+ if (resolve) {
2749
+ resolve(password);
2750
+ }
2751
+ }, onCancel: () => {
2752
+ const reject = state.passwordRequest?.reject;
2753
+ if (reject) {
2754
+ reject();
2755
+ }
2756
+ } })) : state.approvalRequest ? (React.createElement(ApprovalSection, { key: `approval-${state.approvalRequest.message}`, approvalRequest: state.approvalRequest, onApprove: (approved) => {
2567
2757
  const resolve = state.approvalRequest?.resolve;
2568
2758
  if (resolve) {
2569
2759
  resolve(approved);
@@ -2576,7 +2766,7 @@ export const App = ({ onMessage, onCancelRequest, initialModel, initialPlanMode,
2576
2766
  handleSubmit(value, clipboardImages);
2577
2767
  }, autoAcceptMode: state.autoAcceptMode, model: state.currentModel, planMode: state.planMode, commandMode: state.commandMode, backgroundMode: state.backgroundMode, currentWorkingDirectory: state.currentWorkingDirectory, commandHistory: state.commandHistory, onToggleAutoAccept: handleToggleAutoAccept, onToggleCommandMode: onToggleCommandMode, onToggleBackgroundMode: onToggleBackgroundMode, isActive: true, subshellContext: state.subshellContext, subshellContextStack: state.subshellContextStack, currentTokens: state.currentTokens, maxTokens: state.maxTokens, contextLimitReached: state.contextLimitReached, isShellRunning: state.shellState?.isRunning, backgroundTaskCount: state.backgroundTaskCount, subAgentCount: state.subAgentCount, initialValue: preservedInputTextRef.current, onValueChange: handleInputValueChange, onSetAutoModeSetup: (callback) => {
2578
2768
  setAutoModeCallbackRef.current = callback;
2579
- }, sessionQuotaExhausted: state.sessionQuotaExhausted, sessionQuotaTimeRemaining: state.sessionQuotaTimeRemaining, aiAutoSuggestEnabled: state.aiAutoSuggest, sessionCommands: sessionCommands }))),
2769
+ }, sessionQuotaExhausted: state.sessionQuotaExhausted, sessionQuotaTimeRemaining: state.sessionQuotaTimeRemaining, aiAutoSuggestEnabled: state.aiAutoSuggest, sessionCommands: sessionCommands, getCheckpoints: getCheckpoints, onSetInputSetup: onSetInputSetup }))),
2580
2770
  state.showExitWarning && (React.createElement(Box, { marginTop: 1 },
2581
2771
  React.createElement(Text, { color: "#ffaa00", bold: true }, "\u26A0\uFE0F Press Ctrl+C again to exit"))),
2582
2772
  !isConnected && (React.createElement(Box, { marginTop: 0, marginLeft: state.showExitWarning ? 2 : 0 },