centaurus-cli 2.9.5 → 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 (82) hide show
  1. package/dist/cli-adapter.d.ts +27 -2
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +406 -15
  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 +4 -0
  9. package/dist/context/context-manager.d.ts.map +1 -1
  10. package/dist/context/context-manager.js +6 -0
  11. package/dist/context/context-manager.js.map +1 -1
  12. package/dist/context/handlers/docker-handler.d.ts +5 -1
  13. package/dist/context/handlers/docker-handler.d.ts.map +1 -1
  14. package/dist/context/handlers/docker-handler.js +27 -10
  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/tools/background-command.d.ts.map +1 -1
  33. package/dist/tools/background-command.js +132 -24
  34. package/dist/tools/background-command.js.map +1 -1
  35. package/dist/tools/command.d.ts.map +1 -1
  36. package/dist/tools/command.js +14 -4
  37. package/dist/tools/command.js.map +1 -1
  38. package/dist/tools/create-image.d.ts.map +1 -1
  39. package/dist/tools/create-image.js +43 -18
  40. package/dist/tools/create-image.js.map +1 -1
  41. package/dist/tools/file-ops.d.ts.map +1 -1
  42. package/dist/tools/file-ops.js +12 -12
  43. package/dist/tools/file-ops.js.map +1 -1
  44. package/dist/tools/get-diff.d.ts +9 -45
  45. package/dist/tools/get-diff.d.ts.map +1 -1
  46. package/dist/tools/get-diff.js +288 -171
  47. package/dist/tools/get-diff.js.map +1 -1
  48. package/dist/tools/types.d.ts +3 -0
  49. package/dist/tools/types.d.ts.map +1 -1
  50. package/dist/ui/components/App.d.ts +8 -0
  51. package/dist/ui/components/App.d.ts.map +1 -1
  52. package/dist/ui/components/App.js +238 -62
  53. package/dist/ui/components/App.js.map +1 -1
  54. package/dist/ui/components/ConfirmPrompt.d.ts +2 -0
  55. package/dist/ui/components/ConfirmPrompt.d.ts.map +1 -1
  56. package/dist/ui/components/ConfirmPrompt.js +8 -3
  57. package/dist/ui/components/ConfirmPrompt.js.map +1 -1
  58. package/dist/ui/components/InputBox.d.ts +6 -0
  59. package/dist/ui/components/InputBox.d.ts.map +1 -1
  60. package/dist/ui/components/InputBox.js +130 -6
  61. package/dist/ui/components/InputBox.js.map +1 -1
  62. package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
  63. package/dist/ui/components/InteractiveShell.js +47 -12
  64. package/dist/ui/components/InteractiveShell.js.map +1 -1
  65. package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
  66. package/dist/ui/components/ToolExecutionMessage.js +34 -14
  67. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  68. package/dist/utils/ansi-encoder.d.ts +5 -0
  69. package/dist/utils/ansi-encoder.d.ts.map +1 -1
  70. package/dist/utils/ansi-encoder.js +5 -5
  71. package/dist/utils/ansi-encoder.js.map +1 -1
  72. package/dist/utils/editor-utils.d.ts +5 -0
  73. package/dist/utils/editor-utils.d.ts.map +1 -1
  74. package/dist/utils/editor-utils.js +67 -0
  75. package/dist/utils/editor-utils.js.map +1 -1
  76. package/dist/utils/input-classifier.d.ts.map +1 -1
  77. package/dist/utils/input-classifier.js +2 -1
  78. package/dist/utils/input-classifier.js.map +1 -1
  79. package/dist/utils/terminal-output.d.ts.map +1 -1
  80. package/dist/utils/terminal-output.js +162 -103
  81. package/dist/utils/terminal-output.js.map +1 -1
  82. package/package.json +3 -1
@@ -46,6 +46,7 @@ import { BackgroundTaskManager } from './services/background-task-manager.js';
46
46
  import { sessionQuotaManager } from './services/session-quota-manager.js';
47
47
  import { ollamaService, OllamaService } from './services/ollama-service.js';
48
48
  import { workflowStorage } from './services/workflow-storage.js';
49
+ import { CheckpointManager } from './services/checkpoint-manager.js';
49
50
  export class CentaurusCLI {
50
51
  configManager;
51
52
  toolRegistry;
@@ -116,9 +117,16 @@ export class CentaurusCLI {
116
117
  onPromptAnswered; // Callback when AI answers a shell prompt
117
118
  onShowWorkflowCreatorCallback; // Callback to show workflow creator screen with optional initial steps
118
119
  onWorkflowSaveCallback; // Callback when workflow is saved
120
+ onAiAutoSuggestChange; // Callback for AI auto-suggest setting changes
121
+ onRevertToCheckpointCallback; // Callback for revert UI update
122
+ // Checkpoint manager for revert functionality
123
+ checkpointManager;
124
+ currentCheckpointId; // Track current checkpoint being created
119
125
  // Workflow learning mode state
120
126
  workflowLearningActive = false;
121
127
  learnedWorkflowSteps = [];
128
+ // Callback to set input value (e.g., for revert)
129
+ onSetInputCallback = null;
122
130
  constructor() {
123
131
  this.configManager = new ConfigManager();
124
132
  this.toolRegistry = new ToolRegistry();
@@ -233,6 +241,24 @@ export class CentaurusCLI {
233
241
  setOnWorkflowSave(callback) {
234
242
  this.onWorkflowSaveCallback = callback;
235
243
  }
244
+ setOnAiAutoSuggestChange(callback) {
245
+ this.onAiAutoSuggestChange = callback;
246
+ }
247
+ setOnRevertToCheckpoint(callback) {
248
+ this.onRevertToCheckpointCallback = callback;
249
+ }
250
+ /**
251
+ * Get checkpoints for autocomplete dropdown
252
+ */
253
+ getCheckpointsForAutocomplete() {
254
+ if (!this.checkpointManager)
255
+ return [];
256
+ return this.checkpointManager.list().map(cp => ({
257
+ id: cp.id,
258
+ prompt: cp.prompt,
259
+ timestamp: new Date(cp.createdAt)
260
+ }));
261
+ }
236
262
  /**
237
263
  * Save a workflow from the workflow creator UI
238
264
  */
@@ -395,7 +421,10 @@ Begin executing now, starting with Step 1.`;
395
421
  // starting 'fresh' in the remote user's home/default dir is safer for 'warpify' semantics regardless of type.
396
422
  // However, to avoid regression for WSL/Docker usage where inheritance is expected, we restricts this fix to SSH.
397
423
  const initialCwd = type === 'ssh' ? undefined : this.cwd;
398
- context = await detection.handler.connect(command, initialCwd);
424
+ // Use factory method to create a new handler instance for this session
425
+ // This prevents state issues where the singleton handler's client is overwritten by nested sessions
426
+ const newHandler = detection.handler.createNew();
427
+ context = await newHandler.connect(command, initialCwd);
399
428
  }
400
429
  this.connectionCommandStack.push(command);
401
430
  this.contextManager.pushContext(context);
@@ -1279,12 +1308,104 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1279
1308
  // Start logging session and log user message
1280
1309
  conversationLogger.startSession();
1281
1310
  conversationLogger.logUserMessage(message);
1311
+ // Initialize and start checkpoint for revert functionality
1312
+ if (!this.checkpointManager) {
1313
+ this.checkpointManager = new CheckpointManager();
1314
+ }
1315
+ // Ensure we have a chat ID for checkpoints (even for the first message)
1316
+ if (!this.currentChatId) {
1317
+ this.currentChatId = localChatStorage.generateChatId();
1318
+ }
1319
+ if (this.currentChatId) {
1320
+ this.checkpointManager.setCurrentChatId(this.currentChatId);
1321
+ this.currentCheckpointId = undefined;
1322
+ // Start checkpoint snapshot for this user message
1323
+ const currentContext = this.contextManager.getCurrentContext();
1324
+ // Build remote session info and handler for non-local contexts
1325
+ let remoteSessionInfo;
1326
+ let remoteHandler;
1327
+ if (currentContext.type !== 'local' && currentContext.handler) {
1328
+ const metadata = currentContext.metadata;
1329
+ remoteSessionInfo = {
1330
+ hostname: metadata.hostname,
1331
+ username: metadata.username,
1332
+ sessionId: currentContext.sessionId,
1333
+ connectionString: metadata.hostname
1334
+ ? `${metadata.username || 'user'}@${metadata.hostname}`
1335
+ : metadata.distroName || metadata.containerId || undefined,
1336
+ };
1337
+ // Use the handler if it implements the RemoteFileHandler interface
1338
+ if (typeof currentContext.handler.readFile === 'function' &&
1339
+ typeof currentContext.handler.writeFile === 'function' &&
1340
+ typeof currentContext.handler.executeCommand === 'function' &&
1341
+ typeof currentContext.handler.isConnected === 'function') {
1342
+ remoteHandler = currentContext.handler;
1343
+ }
1344
+ }
1345
+ try {
1346
+ const checkpointStartPromise = this.checkpointManager.startCheckpoint({
1347
+ prompt: message,
1348
+ cwd: this.cwd,
1349
+ contextType: currentContext.type,
1350
+ conversationIndex: this.conversationHistory.length - 1,
1351
+ uiMessageIndex: this.uiMessageHistory.length,
1352
+ remoteSessionInfo,
1353
+ handler: remoteHandler,
1354
+ });
1355
+ let checkpoint = null;
1356
+ if (currentContext.type !== 'local') {
1357
+ const remoteCheckpointTimeoutMs = 2000;
1358
+ const timeoutMarker = '__checkpoint_timeout__';
1359
+ let timeoutHandle = null;
1360
+ const timeoutPromise = new Promise((resolve) => {
1361
+ timeoutHandle = setTimeout(() => resolve(timeoutMarker), remoteCheckpointTimeoutMs);
1362
+ });
1363
+ const checkpointOrTimeout = await Promise.race([
1364
+ checkpointStartPromise,
1365
+ timeoutPromise,
1366
+ ]);
1367
+ if (timeoutHandle) {
1368
+ clearTimeout(timeoutHandle);
1369
+ }
1370
+ if (checkpointOrTimeout === timeoutMarker) {
1371
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Remote checkpoint start exceeded ${remoteCheckpointTimeoutMs}ms (${currentContext.type}); continuing without blocking AI turn\n`);
1372
+ void checkpointStartPromise
1373
+ .then((lateCheckpoint) => {
1374
+ if (!lateCheckpoint || !this.checkpointManager) {
1375
+ return;
1376
+ }
1377
+ this.checkpointManager.discardCheckpointById(lateCheckpoint.id);
1378
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Discarded late checkpoint ${lateCheckpoint.id} created after timeout\n`);
1379
+ })
1380
+ .catch((lateError) => {
1381
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Late checkpoint creation failed after timeout: ${lateError?.message || lateError}\n`);
1382
+ });
1383
+ }
1384
+ else {
1385
+ checkpoint = checkpointOrTimeout;
1386
+ }
1387
+ }
1388
+ else {
1389
+ checkpoint = await checkpointStartPromise;
1390
+ }
1391
+ if (checkpoint) {
1392
+ this.currentCheckpointId = checkpoint.id;
1393
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Started checkpoint ${checkpoint.id} (${currentContext.type}) for: "${message.slice(0, 50)}..."\n`);
1394
+ }
1395
+ }
1396
+ catch (error) {
1397
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Failed to start checkpoint: ${error.message}\n`);
1398
+ }
1399
+ }
1282
1400
  try {
1283
1401
  const tools = this.toolRegistry.getSchemas();
1284
1402
  const context = {
1285
1403
  cwd: this.cwd,
1286
1404
  contextManager: this.contextManager,
1287
1405
  cliAdapter: this, // Pass CLI adapter reference for interactive process management
1406
+ checkpointManager: this.checkpointManager, // For session-aware diff tool
1407
+ currentCheckpointId: this.currentCheckpointId, // Active checkpoint for this request
1408
+ currentChatId: this.currentChatId || undefined, // Current chat session ID (for session-wide diffs)
1288
1409
  requireApproval: async (message, risky, preview, operationType, operationDetails) => {
1289
1410
  // Special bypass for shell input to running processes:
1290
1411
  // If the AI is sending input to an existing shell (via shell_input), we bypass the separate approval step.
@@ -2391,6 +2512,17 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
2391
2512
  throw new Error(`AI Error: ${error.message}`);
2392
2513
  }
2393
2514
  finally {
2515
+ // Finalize checkpoint if one was created for this message
2516
+ if (this.currentCheckpointId && this.checkpointManager) {
2517
+ try {
2518
+ await this.checkpointManager.finalizeCheckpoint(this.currentCheckpointId);
2519
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Finalized checkpoint ${this.currentCheckpointId}\n`);
2520
+ }
2521
+ catch (error) {
2522
+ quickLog(`[${new Date().toISOString()}] [Checkpoint] Failed to finalize checkpoint: ${error.message}\n`);
2523
+ }
2524
+ this.currentCheckpointId = undefined;
2525
+ }
2394
2526
  // Clean up abort controller
2395
2527
  this.currentAbortController = undefined;
2396
2528
  }
@@ -2813,12 +2945,18 @@ Start by listing the directory structure to understand what you're working with.
2813
2945
  const value = args[1].toLowerCase();
2814
2946
  if (value === 'on') {
2815
2947
  this.configManager.set('aiAutoSuggest', true);
2948
+ if (this.onAiAutoSuggestChange) {
2949
+ this.onAiAutoSuggestChange(true);
2950
+ }
2816
2951
  responseMessage = '✅ **AI Auto-Suggestions Enabled**\n\n' +
2817
2952
  'From now on, I will suggest commands after 5 seconds of inactivity.\n' +
2818
2953
  'Suggestions will appear in grey text. Use the **Right Arrow** key to accept them.';
2819
2954
  }
2820
2955
  else if (value === 'off') {
2821
2956
  this.configManager.set('aiAutoSuggest', false);
2957
+ if (this.onAiAutoSuggestChange) {
2958
+ this.onAiAutoSuggestChange(false);
2959
+ }
2822
2960
  responseMessage = '✅ **AI Auto-Suggestions Disabled**\n\n' +
2823
2961
  'I will no longer provide AI-powered command suggestions.';
2824
2962
  }
@@ -3075,6 +3213,189 @@ Then try /models local again.`;
3075
3213
  case 'exit':
3076
3214
  process.exit(0);
3077
3215
  break;
3216
+ case 'revert': {
3217
+ const checkpointArg = args[0];
3218
+ if (!checkpointArg) {
3219
+ responseMessage = '❌ Usage: `/revert <checkpoint-id>`\nType `/revert ` and use autocomplete to select a checkpoint.';
3220
+ break;
3221
+ }
3222
+ if (!this.checkpointManager) {
3223
+ responseMessage = '❌ No checkpoints available in this session.';
3224
+ break;
3225
+ }
3226
+ try {
3227
+ // Smart context matching: determine if we can revert based on current vs checkpoint context
3228
+ const currentContext = this.contextManager.getCurrentContext();
3229
+ const allCheckpoints = this.checkpointManager.list();
3230
+ const targetCheckpoint = allCheckpoints.find(c => c.id === checkpointArg);
3231
+ if (!targetCheckpoint) {
3232
+ responseMessage = `❌ Checkpoint "${checkpointArg}" not found.`;
3233
+ break;
3234
+ }
3235
+ // Context validation for revert
3236
+ const cpContextType = targetCheckpoint.contextType;
3237
+ const currentContextType = currentContext.type;
3238
+ if (cpContextType !== 'local' && currentContextType === 'local') {
3239
+ // Remote checkpoint but we're local — can't reconnect to SSH to revert files
3240
+ const sessionType = cpContextType.toUpperCase();
3241
+ const sessionInfo = targetCheckpoint.remoteSessionInfo;
3242
+ const target = sessionInfo?.connectionString || sessionInfo?.hostname || 'the remote machine';
3243
+ responseMessage = `❌ This checkpoint was created during a ${sessionType} session (${target}).\n` +
3244
+ `You are not currently connected to that session.\n\n` +
3245
+ `Please reconnect to the ${sessionType} session first, then retry /revert.`;
3246
+ break;
3247
+ }
3248
+ if (cpContextType === 'local' && currentContextType !== 'local') {
3249
+ // Local checkpoint but we're in a remote session — wrong context
3250
+ responseMessage = `❌ This checkpoint was created in a local session.\n` +
3251
+ `You are currently in a ${currentContextType.toUpperCase()} session.\n\n` +
3252
+ `Please exit your remote session first, then retry /revert.`;
3253
+ break;
3254
+ }
3255
+ if (cpContextType !== 'local' && currentContextType !== 'local' && cpContextType !== currentContextType) {
3256
+ // Both remote but different types (e.g., SSH checkpoint but in Docker now)
3257
+ responseMessage = `❌ This checkpoint was created in a ${cpContextType.toUpperCase()} session, ` +
3258
+ `but you are currently in a ${currentContextType.toUpperCase()} session.\n\n` +
3259
+ `Please connect to the correct ${cpContextType.toUpperCase()} session first.`;
3260
+ break;
3261
+ }
3262
+ // For remote checkpoints with mismatched hosts, validate the connection target
3263
+ if (cpContextType !== 'local' && targetCheckpoint.remoteSessionInfo) {
3264
+ const cpHost = targetCheckpoint.remoteSessionInfo.hostname;
3265
+ const currentHost = currentContext.metadata.hostname;
3266
+ if (cpHost && currentHost && cpHost !== currentHost) {
3267
+ responseMessage = `❌ This checkpoint was created on ${cpHost}, ` +
3268
+ `but you are currently connected to ${currentHost}.\n\n` +
3269
+ `Please connect to ${cpHost} first, then retry /revert.`;
3270
+ break;
3271
+ }
3272
+ }
3273
+ // Build handler for remote revert if needed
3274
+ let revertHandler;
3275
+ if (cpContextType !== 'local' && currentContext.handler) {
3276
+ if (typeof currentContext.handler.readFile === 'function' &&
3277
+ typeof currentContext.handler.writeFile === 'function' &&
3278
+ typeof currentContext.handler.executeCommand === 'function' &&
3279
+ typeof currentContext.handler.isConnected === 'function') {
3280
+ revertHandler = currentContext.handler;
3281
+ }
3282
+ }
3283
+ const result = await this.checkpointManager.revertToCheckpoint(checkpointArg, revertHandler);
3284
+ // Find checkpoint index for UI truncation
3285
+ const checkpoints = this.checkpointManager.list();
3286
+ const checkpointIndex = checkpoints.findIndex(c => c.id === checkpointArg);
3287
+ // Truncate conversation history to the checkpoint
3288
+ if (checkpointIndex >= 0) {
3289
+ const checkpoint = result.checkpoint;
3290
+ // Populate the input bar with the reverted prompt
3291
+ if (this.onSetInputCallback) {
3292
+ this.onSetInputCallback(checkpoint.prompt);
3293
+ }
3294
+ // Use conversationIndex from metadata if available (robust)
3295
+ // This avoids issues with duplicate prompts getting truncated at the wrong occurrence
3296
+ if (typeof checkpoint.conversationIndex === 'number' &&
3297
+ checkpoint.conversationIndex >= 0 &&
3298
+ checkpoint.conversationIndex < this.conversationHistory.length) {
3299
+ // Truncate to index (exclusive) to remove the message from history
3300
+ // This allows the user to "edit" the message in the input bar
3301
+ this.conversationHistory = this.conversationHistory.slice(0, checkpoint.conversationIndex);
3302
+ quickLog(`[${new Date().toISOString()}] [Revert] Truncated conversation history to index ${checkpoint.conversationIndex} (exclusive)\n`);
3303
+ }
3304
+ else {
3305
+ // Fallback to string matching (legacy or missing index)
3306
+ const targetPrompt = checkpoint.prompt;
3307
+ // Find the user message with this prompt in conversationHistory
3308
+ let foundIndex = -1;
3309
+ // Use a broader search to find the matching message
3310
+ // The prompt might be a substring or exact match
3311
+ const searchPrompt = targetPrompt.slice(0, 50);
3312
+ for (let i = 0; i < this.conversationHistory.length; i++) {
3313
+ const msg = this.conversationHistory[i];
3314
+ if (msg.role === 'user' && msg.content && msg.content.includes(searchPrompt)) {
3315
+ foundIndex = i;
3316
+ break;
3317
+ }
3318
+ }
3319
+ if (foundIndex >= 0) {
3320
+ // Keep messages up to but NOT including this user message
3321
+ this.conversationHistory = this.conversationHistory.slice(0, foundIndex);
3322
+ quickLog(`[${new Date().toISOString()}] [Revert] Truncated conversation history to index ${foundIndex} (exclusive, using string match)\n`);
3323
+ }
3324
+ }
3325
+ // Fix: Also truncate UI message history to ensure consistency
3326
+ // Use uiMessageIndex from metadata if available (robust)
3327
+ if (typeof checkpoint.uiMessageIndex === 'number' &&
3328
+ checkpoint.uiMessageIndex >= 0 &&
3329
+ checkpoint.uiMessageIndex < this.uiMessageHistory.length) {
3330
+ // Truncate to index (exclusive)
3331
+ this.uiMessageHistory = this.uiMessageHistory.slice(0, checkpoint.uiMessageIndex);
3332
+ quickLog(`[${new Date().toISOString()}] [Revert] Truncated UI message history to index ${checkpoint.uiMessageIndex} (exclusive)\n`);
3333
+ }
3334
+ else {
3335
+ // Fallback to string matching (legacy or missing index)
3336
+ const targetPrompt = checkpoint.prompt;
3337
+ const searchPrompt = targetPrompt.slice(0, 50);
3338
+ let foundUiIndex = -1;
3339
+ for (let i = 0; i < this.uiMessageHistory.length; i++) {
3340
+ const msg = this.uiMessageHistory[i];
3341
+ if (msg.role === 'user' && msg.content && msg.content.includes(searchPrompt)) {
3342
+ foundUiIndex = i;
3343
+ break;
3344
+ }
3345
+ }
3346
+ if (foundUiIndex >= 0) {
3347
+ // Keep messages up to but NOT including this user message
3348
+ this.uiMessageHistory = this.uiMessageHistory.slice(0, foundUiIndex);
3349
+ quickLog(`[${new Date().toISOString()}] [Revert] Truncated UI message history to index ${foundUiIndex} (exclusive, using string match)\n`);
3350
+ }
3351
+ }
3352
+ // Remove the reverted checkpoint AND all checkpoints created after it
3353
+ // They should not appear in autocomplete anymore
3354
+ this.checkpointManager.removeCheckpointsFrom(checkpointArg);
3355
+ // Build the success message BEFORE calling handleChatPickerSelection
3356
+ // This will be passed as a parameter and included in the same React setState call
3357
+ quickLog(`[${new Date().toISOString()}] [Revert] Building success message for checkpoint "${checkpointArg}"\n`);
3358
+ const truncatedPrompt = result.checkpoint.prompt.length > 50
3359
+ ? result.checkpoint.prompt.slice(0, 50) + '...'
3360
+ : result.checkpoint.prompt;
3361
+ let revertSuccessMessage = `✅ Reverted to: "${truncatedPrompt}"\n` +
3362
+ `Restored ${result.restored} files, removed ${result.removed} files.`;
3363
+ if (result.errors.length > 0) {
3364
+ revertSuccessMessage += `\n⚠️ Warnings: ${result.errors.join(', ')}`;
3365
+ }
3366
+ // Save the truncated state to disk
3367
+ if (this.currentChatId) {
3368
+ quickLog(`[${new Date().toISOString()}] [Revert] Saving truncated chat to disk (chatId: ${this.currentChatId})\n`);
3369
+ quickLog(`[${new Date().toISOString()}] [Revert] uiMessageHistory.length BEFORE save: ${this.uiMessageHistory.length}\n`);
3370
+ this.saveCurrentChat();
3371
+ // Reload the chat to force UI update ensures consistency
3372
+ // This simulates a /chat resume command which correctly populates the UI
3373
+ // Pass skipLoadedMessage=true and the revert success message
3374
+ // The success message is appended to restoredMessages in handleChatPickerSelection
3375
+ // This ensures it's part of the same React setState call, avoiding race conditions
3376
+ quickLog(`[${new Date().toISOString()}] [Revert] Calling handleChatPickerSelection with revertSuccessMessage="${revertSuccessMessage.substring(0, 50)}..."\n`);
3377
+ const currentContext = this.contextManager.getCurrentContext();
3378
+ const preserveRemote = currentContext.type !== 'local' && typeof currentContext.handler?.isConnected === 'function'
3379
+ ? currentContext.handler.isConnected()
3380
+ : currentContext.type !== 'local';
3381
+ await this.handleChatPickerSelection(this.currentChatId, true, revertSuccessMessage, preserveRemote);
3382
+ // Clear responseMessage so it doesn't get sent via onDirectMessageCallback
3383
+ // (the message was already included in the restored messages above)
3384
+ quickLog(`[${new Date().toISOString()}] [Revert] handleChatPickerSelection completed, clearing responseMessage\n`);
3385
+ responseMessage = '';
3386
+ }
3387
+ else {
3388
+ // Fallback: if no current chat, show the message via normal callback
3389
+ quickLog(`[${new Date().toISOString()}] [Revert] No currentChatId, using fallback responseMessage\n`);
3390
+ responseMessage = revertSuccessMessage;
3391
+ }
3392
+ }
3393
+ }
3394
+ catch (error) {
3395
+ responseMessage = `❌ Failed to revert: ${error.message}`;
3396
+ }
3397
+ break;
3398
+ }
3078
3399
  case 'add-command':
3079
3400
  case 'add-command-auto-detect':
3080
3401
  // Handle custom command auto-detect management
@@ -3945,7 +4266,7 @@ Create once, run many times across different machines.`;
3945
4266
  /**
3946
4267
  * Load a chat from local storage and restore it
3947
4268
  */
3948
- loadChat(chatId) {
4269
+ loadChat(chatId, options) {
3949
4270
  const chat = localChatStorage.loadChat(chatId);
3950
4271
  if (!chat) {
3951
4272
  return false;
@@ -3957,11 +4278,32 @@ Create once, run many times across different machines.`;
3957
4278
  tool_calls: msg.tool_calls,
3958
4279
  tool_call_id: msg.tool_call_id,
3959
4280
  }));
4281
+ // IMPORTANT: Also restore UI message history from saved chat
4282
+ // This ensures that when the chat is saved again (e.g., after revert),
4283
+ // the correct UI messages are preserved
4284
+ if (chat.uiMessages && chat.uiMessages.length > 0) {
4285
+ this.uiMessageHistory = chat.uiMessages.map(msg => ({
4286
+ id: msg.id,
4287
+ role: msg.role,
4288
+ content: msg.content,
4289
+ timestamp: msg.timestamp ? new Date(msg.timestamp) : undefined,
4290
+ toolExecution: msg.toolExecution,
4291
+ shouldStream: false,
4292
+ isCommandMode: msg.isCommandMode,
4293
+ tool_call_id: msg.tool_call_id,
4294
+ tool_calls: msg.tool_calls,
4295
+ thinkingDuration: msg.thinkingDuration,
4296
+ taskCompletion: msg.taskCompletion,
4297
+ planAccepted: msg.planAccepted,
4298
+ connectionStatus: msg.connectionStatus,
4299
+ }));
4300
+ }
3960
4301
  // Set the current chat ID to continue the conversation
3961
4302
  this.currentChatId = chatId;
3962
4303
  this.conversationStarted = true;
3963
4304
  // Restore CWD if saved (important for commands to run in correct directory)
3964
- if (chat.cwd) {
4305
+ // Allow skipping when preserving an active remote session
4306
+ if (chat.cwd && !options?.preserveCwd) {
3965
4307
  this.cwd = chat.cwd;
3966
4308
  // Update context manager if available
3967
4309
  if (this.contextManager) {
@@ -3993,8 +4335,12 @@ Create once, run many times across different machines.`;
3993
4335
  }
3994
4336
  /**
3995
4337
  * Handle chat picker selection
4338
+ * @param chatId The ID of the chat to load
4339
+ * @param skipLoadedMessage If true, skip showing the "Loaded chat" message (used during revert to avoid React setState race condition)
4340
+ * @param revertSuccessMessage If provided, this message will be appended to restoredMessages before calling onRestoreMessagesCallback (used during revert to include success message in the same React setState call, avoiding race conditions)
4341
+ * @param preserveRemoteSession If true, keep current SSH/WSL/Docker connection and skip reconnect logic (used during revert in a live remote session)
3996
4342
  */
3997
- async handleChatPickerSelection(chatId) {
4343
+ async handleChatPickerSelection(chatId, skipLoadedMessage = false, revertSuccessMessage, preserveRemoteSession = false) {
3998
4344
  const chat = localChatStorage.loadChat(chatId);
3999
4345
  if (!chat) {
4000
4346
  if (this.onResponseCallback) {
@@ -4005,7 +4351,8 @@ Create once, run many times across different machines.`;
4005
4351
  // IMPORTANT: Clean up current remote session before loading new chat
4006
4352
  // This ensures that switching from a remote chat to a local chat properly resets the state
4007
4353
  const currentContext = this.contextManager.getCurrentContext();
4008
- if (currentContext.type !== 'local') {
4354
+ const shouldPreserveRemote = preserveRemoteSession && currentContext.type !== 'local';
4355
+ if (!shouldPreserveRemote && currentContext.type !== 'local') {
4009
4356
  // Disconnect from current remote session
4010
4357
  if (currentContext.handler) {
4011
4358
  try {
@@ -4022,8 +4369,18 @@ Create once, run many times across different machines.`;
4022
4369
  this.connectionCommandStack = [];
4023
4370
  }
4024
4371
  // Load AI context
4025
- this.loadChat(chatId);
4372
+ this.loadChat(chatId, shouldPreserveRemote ? { preserveCwd: true } : undefined);
4373
+ // If we're preserving a live remote session (revert case), restore the current remote CWD
4374
+ if (shouldPreserveRemote) {
4375
+ this.cwd = currentContext.metadata.workingDirectory;
4376
+ this.contextManager.updateWorkingDirectory(this.cwd);
4377
+ if (this.onCwdChange) {
4378
+ this.onCwdChange(this.cwd);
4379
+ }
4380
+ }
4026
4381
  // Restore UI messages if available
4382
+ quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] chat.uiMessages exists: ${!!chat.uiMessages}, length: ${chat.uiMessages?.length ?? 0}\n`);
4383
+ quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] onRestoreMessagesCallback exists: ${!!this.onRestoreMessagesCallback}\n`);
4027
4384
  if (chat.uiMessages && chat.uiMessages.length > 0 && this.onRestoreMessagesCallback) {
4028
4385
  // Convert StoredUIMessage back to Message format
4029
4386
  const restoredMessages = chat.uiMessages.map(msg => ({
@@ -4041,11 +4398,29 @@ Create once, run many times across different machines.`;
4041
4398
  planAccepted: msg.planAccepted,
4042
4399
  connectionStatus: msg.connectionStatus, // For SSH/WSL/Docker connection status boxes
4043
4400
  }));
4401
+ // If a revert success message was provided, append it to the restored messages
4402
+ // This ensures the message is part of the same React setState call, avoiding race conditions
4403
+ quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] revertSuccessMessage provided: ${!!revertSuccessMessage}\n`);
4404
+ if (revertSuccessMessage) {
4405
+ const revertSystemMessage = {
4406
+ id: `revert-success-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
4407
+ role: 'assistant',
4408
+ content: revertSuccessMessage,
4409
+ timestamp: new Date(),
4410
+ shouldStream: false,
4411
+ };
4412
+ restoredMessages.push(revertSystemMessage);
4413
+ quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] Appended revert success message, restoredMessages.length: ${restoredMessages.length}\n`);
4414
+ }
4415
+ quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] Calling onRestoreMessagesCallback with ${restoredMessages.length} messages\n`);
4044
4416
  this.onRestoreMessagesCallback(restoredMessages);
4417
+ quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] onRestoreMessagesCallback completed\n`);
4045
4418
  }
4046
4419
  // Attempt to restore remote context if chat was saved while in remote environment
4047
4420
  // Support both remoteContextStack (for nested sessions) and single remoteContext (backward compat)
4048
- const contextStackToRestore = chat.remoteContextStack ?? (chat.remoteContext ? [chat.remoteContext] : null);
4421
+ const contextStackToRestore = shouldPreserveRemote
4422
+ ? null
4423
+ : (chat.remoteContextStack ?? (chat.remoteContext ? [chat.remoteContext] : null));
4049
4424
  if (contextStackToRestore && contextStackToRestore.length > 0) {
4050
4425
  // Get the base local CWD from the first context's localCwdBeforeRemote
4051
4426
  const baseLocalCwd = contextStackToRestore[0].localCwdBeforeRemote;
@@ -4082,12 +4457,15 @@ Create once, run many times across different machines.`;
4082
4457
  if (!detection) {
4083
4458
  throw new Error(`Could not detect handler for: ${connectionCommand}`);
4084
4459
  }
4460
+ // Create a new instance of the handler to ensure each level of the connection
4461
+ // has its own state (client, stream, etc.), critical for nested sessions of the same type
4462
+ const handler = detection.handler.createNew();
4085
4463
  let context;
4086
4464
  // Check if this is a nested connection (i > 0 means we're inside a remote session)
4087
4465
  if (i > 0) {
4088
4466
  const currentContext = this.contextManager.getCurrentContext();
4089
- if (detection.handler.connectFromRemote) {
4090
- context = await detection.handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
4467
+ if (handler.connectFromRemote) {
4468
+ context = await handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
4091
4469
  }
4092
4470
  else {
4093
4471
  throw new Error(`Nested connections not supported by ${type} handler`);
@@ -4095,7 +4473,7 @@ Create once, run many times across different machines.`;
4095
4473
  }
4096
4474
  else {
4097
4475
  // First level: connect from local
4098
- context = await detection.handler.connect(connectionCommand, previousCwd);
4476
+ context = await handler.connect(connectionCommand, previousCwd);
4099
4477
  }
4100
4478
  // Push to stacks
4101
4479
  this.connectionCommandStack.push(connectionCommand);
@@ -4167,9 +4545,8 @@ Create once, run many times across different machines.`;
4167
4545
  }
4168
4546
  return;
4169
4547
  }
4170
- // No remote context - show regular confirmation
4171
4548
  // No remote context - show regular confirmation and restore CWD
4172
- if (chat.cwd && !chat.remoteContext) {
4549
+ if (!shouldPreserveRemote && chat.cwd && !chat.remoteContext) {
4173
4550
  if (fs.existsSync(chat.cwd)) {
4174
4551
  this.cwd = chat.cwd;
4175
4552
  this.contextManager.updateWorkingDirectory(chat.cwd);
@@ -4178,7 +4555,8 @@ Create once, run many times across different machines.`;
4178
4555
  }
4179
4556
  }
4180
4557
  }
4181
- if (this.onDirectMessageCallback) {
4558
+ // Skip loaded message if called from revert (to avoid React setState race condition)
4559
+ if (this.onDirectMessageCallback && !skipLoadedMessage) {
4182
4560
  const responseMessage = `✅ Loaded chat: "${chat.title}"\n\nYou have ${chat.messageCount} messages in AI context. Continue your conversation!`;
4183
4561
  this.onDirectMessageCallback(responseMessage);
4184
4562
  }
@@ -4210,12 +4588,19 @@ Create once, run many times across different machines.`;
4210
4588
  * Start a new chat session
4211
4589
  */
4212
4590
  startNewChat() {
4591
+ const currentContext = this.contextManager.getCurrentContext();
4213
4592
  this.conversationHistory = [];
4214
4593
  this.currentChatId = null;
4215
4594
  this.conversationStarted = false;
4216
4595
  this.uiMessageHistory = [];
4217
- this.cwdStack = [];
4218
- this.connectionCommandStack = [];
4596
+ // Only clear stacks if we're in local context
4597
+ // If we're in a remote context (SSH/WSL/Docker), we want to PRESERVE the connection state
4598
+ // so the new chat continues in the same environment
4599
+ if (currentContext.type === 'local') {
4600
+ this.cwdStack = [];
4601
+ this.connectionCommandStack = [];
4602
+ }
4603
+ // Else: Preserve cwdStack and connectionCommandStack for remote sessions
4219
4604
  // Reset context limit state
4220
4605
  if (this.contextLimitReached) {
4221
4606
  this.contextLimitReached = false;
@@ -4256,6 +4641,12 @@ Create once, run many times across different machines.`;
4256
4641
  setOnRestoreMessagesCallback(callback) {
4257
4642
  this.onRestoreMessagesCallback = callback;
4258
4643
  }
4644
+ /**
4645
+ * Set callback for setting input value (e.g., for revert)
4646
+ */
4647
+ setOnSetInput(callback) {
4648
+ this.onSetInputCallback = callback;
4649
+ }
4259
4650
  /**
4260
4651
  * Get environment context for backend
4261
4652
  * Returns structured environment information to be sent to backend