diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 0f5ef1f8dde..7070b3d5936 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -131,6 +131,46 @@ export default { 'Canviar el nom de la conversa actual. --auto permet que el model ràpid triï un títol.', 'Rewind conversation to a previous turn': 'Rebobinar la conversa fins a un torn anterior', + 'Rewind Conversation': 'Rebobinar la conversa', + 'No user turns to rewind to.': "No hi ha torns d'usuari per rebobinar.", + 'Rewind to: ': 'Rebobinar a: ', + 'Restore code and conversation': 'Restaura el codi i la conversa', + 'Restore conversation only': 'Restaura només la conversa', + 'Restore code only': 'Restaura només el codi', + 'Never mind': 'Tant és', + 'Computing file changes...': "S'estan calculant els canvis als fitxers...", + 'Restoring...': "S'està restaurant...", + 'Restored {{count}} file(s).': "S'han restaurat {{count}} fitxer(s).", + 'Failed to restore files: {{error}}': + 'Error en restaurar els fitxers: {{error}}', + 'Rewind failed: {{error}}': 'Error en retrocedir: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'No es pot retrocedir la conversa: cap client de model actiu.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Codi restaurat, però la conversa no s’ha pogut retrocedir (cap client actiu).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversa retrocedida. Edita la teva indicació i prem Retorn per continuar.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'El retrocés no afecta els fitxers editats manualment o mitjançant comandes de shell.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'No es pot retrocedir a un torn que ha estat comprimit. Prova amb un torn més recent.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'La restauració de fitxers no està disponible per a aquest torn (no s’han capturat canvis, o aquest torn és anterior a la sessió actual).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} en {{count}} fitxer)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} en {{count}} fitxers)', + 'Failed to restore {{count}} file(s): {{files}}': + 'Error en restaurar {{count}} fitxer(s): {{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'No es poden restaurar els fitxers: aquest torn es va crear abans que el punt de control de fitxers estigués habilitat.', + 'No files needed to be restored.': 'Cap fitxer necessitava restauració.', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ per navegar · Enter per seleccionar · Esc per tornar', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ per navegar · Enter per seleccionar · Esc per cancel·lar', + 'Enter/Y to confirm · Esc/N to go back': + 'Enter/Y per confirmar · Esc/N per tornar', 'change the theme': 'canviar el tema', 'Select Theme': 'Seleccionar tema', Preview: 'Previsualització', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 996306eaf93..6d855a1ef5e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -110,6 +110,46 @@ export default { 'Die aktuelle Unterhaltung umbenennen. Mit --auto lässt du das schnelle Modell einen Titel wählen.', 'Rewind conversation to a previous turn': 'Die Unterhaltung auf einen früheren Gesprächsschritt zurücksetzen', + 'Rewind Conversation': 'Unterhaltung zurückspulen', + 'No user turns to rewind to.': 'Keine Benutzerrunden zum Zurückspulen.', + 'Rewind to: ': 'Zurückspulen zu: ', + 'Restore code and conversation': 'Code und Unterhaltung wiederherstellen', + 'Restore conversation only': 'Nur Unterhaltung wiederherstellen', + 'Restore code only': 'Nur Code wiederherstellen', + 'Never mind': 'Egal', + 'Computing file changes...': 'Dateiänderungen werden berechnet...', + 'Restoring...': 'Wiederherstellung läuft...', + 'Restored {{count}} file(s).': '{{count}} Datei(en) wiederhergestellt.', + 'Failed to restore files: {{error}}': + 'Fehler beim Wiederherstellen der Dateien: {{error}}', + 'Rewind failed: {{error}}': 'Zurückspulen fehlgeschlagen: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Konversation kann nicht zurückgespult werden: kein aktiver Modell-Client.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Code wiederhergestellt, aber Konversation konnte nicht zurückgespult werden (kein aktiver Client).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Konversation zurückgespult. Bearbeite deinen Prompt und drücke Enter, um fortzufahren.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Das Zurückspulen wirkt sich nicht auf Dateien aus, die manuell oder per Shell-Befehl geändert wurden.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Zu einem komprimierten Turn kann nicht zurückgespult werden. Bitte einen aktuelleren Turn versuchen.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'Datei-Wiederherstellung ist für diesen Turn nicht verfügbar (keine erfassten Dateiänderungen, oder dieser Turn liegt vor der aktuellen Sitzung).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} in {{count}} Datei)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} in {{count}} Dateien)', + 'Failed to restore {{count}} file(s): {{files}}': + '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Dateien können nicht wiederhergestellt werden: Dieser Turn wurde erstellt, bevor Datei-Checkpointing aktiviert war.', + 'No files needed to be restored.': + 'Keine Dateien mussten wiederhergestellt werden.', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ navigieren · Enter auswählen · Esc zurück', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ navigieren · Enter auswählen · Esc abbrechen', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y bestätigen · Esc/N zurück', 'change the theme': 'Design ändern', 'Select Theme': 'Design auswählen', Preview: 'Vorschau', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 23a70814456..e1b931cfdd3 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -132,6 +132,45 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.', 'Rewind conversation to a previous turn': 'Rewind conversation to a previous turn', + 'Rewind Conversation': 'Rewind Conversation', + 'No user turns to rewind to.': 'No user turns to rewind to.', + 'Rewind to: ': 'Rewind to: ', + 'Restore code and conversation': 'Restore code and conversation', + 'Restore conversation only': 'Restore conversation only', + 'Restore code only': 'Restore code only', + 'Never mind': 'Never mind', + 'Computing file changes...': 'Computing file changes...', + 'Restoring...': 'Restoring...', + 'Restored {{count}} file(s).': 'Restored {{count}} file(s).', + 'Failed to restore files: {{error}}': 'Failed to restore files: {{error}}', + 'Rewind failed: {{error}}': 'Rewind failed: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Cannot rewind conversation: no active model client.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Code restored, but conversation could not be rewound (no active client).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversation rewound. Edit your prompt and press Enter to continue.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Rewinding does not affect files edited manually or via shell commands.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} in {{count}} file)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} in {{count}} files)', + 'Failed to restore {{count}} file(s): {{files}}': + 'Failed to restore {{count}} file(s): {{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Cannot restore files: this turn was created before file checkpointing was enabled.', + 'No files needed to be restored.': 'No files needed to be restored.', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ to navigate · Enter to select · Esc to go back', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ to navigate · Enter to select · Esc to cancel', + 'Enter/Y to confirm · Esc/N to go back': + 'Enter/Y to confirm · Esc/N to go back', 'change the theme': 'change the theme', 'Select Theme': 'Select Theme', Preview: 'Preview', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index f4f02f592f0..cfce04e60ea 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -130,6 +130,47 @@ export default { 'Renommer la conversation en cours. --auto laisse le modèle rapide choisir un titre.', 'Rewind conversation to a previous turn': 'Revenir à un tour précédent de la conversation', + 'Rewind Conversation': 'Rembobiner la conversation', + 'No user turns to rewind to.': + 'Aucun tour utilisateur vers lequel rembobiner.', + 'Rewind to: ': 'Rembobiner vers : ', + 'Restore code and conversation': 'Restaurer le code et la conversation', + 'Restore conversation only': 'Restaurer la conversation uniquement', + 'Restore code only': 'Restaurer le code uniquement', + 'Never mind': 'Annuler', + 'Computing file changes...': 'Calcul des modifications de fichiers...', + 'Restoring...': 'Restauration en cours...', + 'Restored {{count}} file(s).': '{{count}} fichier(s) restauré(s).', + 'Failed to restore files: {{error}}': + 'Échec de la restauration des fichiers : {{error}}', + 'Rewind failed: {{error}}': 'Échec du retour en arrière : {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Impossible de revenir en arrière sur la conversation : aucun client de modèle actif.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Code restauré, mais la conversation n’a pas pu être ramenée en arrière (aucun client actif).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversation ramenée en arrière. Modifiez votre invite et appuyez sur Entrée pour continuer.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Le retour en arrière n’affecte pas les fichiers édités manuellement ou via des commandes shell.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Impossible de revenir à un tour qui a été compressé. Essayez un tour plus récent.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'La restauration des fichiers est indisponible pour ce tour (aucune modification capturée, ou ce tour est antérieur à la session actuelle).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} dans {{count}} fichier)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} dans {{count}} fichiers)', + 'Failed to restore {{count}} file(s): {{files}}': + 'Échec de la restauration de {{count}} fichier(s) : {{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + "Impossible de restaurer les fichiers : ce tour a été créé avant l'activation des points de contrôle de fichiers.", + 'No files needed to be restored.': + "Aucun fichier n'a eu besoin d'être restauré.", + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ naviguer · Enter sélectionner · Esc retour', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ naviguer · Enter sélectionner · Esc annuler', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y confirmer · Esc/N retour', 'change the theme': 'changer le thème', 'Select Theme': 'Sélectionner un thème', Preview: 'Aperçu', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 62fb9ff879a..9b7e9a49318 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -91,6 +91,45 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '現在の会話の名前を変更する。--auto を使うと高速モデルがタイトルを決めます。', 'Rewind conversation to a previous turn': '会話を前のターンまで巻き戻す', + 'Rewind Conversation': '会話を巻き戻す', + 'No user turns to rewind to.': '巻き戻せるユーザーターンがありません。', + 'Rewind to: ': '巻き戻し先:', + 'Restore code and conversation': 'コードと会話を復元', + 'Restore conversation only': '会話のみ復元', + 'Restore code only': 'コードのみ復元', + 'Never mind': 'やめる', + 'Computing file changes...': 'ファイルの変更を計算中...', + 'Restoring...': '復元中...', + 'Restored {{count}} file(s).': '{{count}} 個のファイルを復元しました。', + 'Failed to restore files: {{error}}': + 'ファイルの復元に失敗しました:{{error}}', + 'Rewind failed: {{error}}': '巻き戻しに失敗しました:{{error}}', + 'Cannot rewind conversation: no active model client.': + '会話を巻き戻せません:アクティブなモデルクライアントがありません。', + 'Code restored, but conversation could not be rewound (no active client).': + 'コードは復元されましたが、会話は巻き戻せませんでした(モデルクライアントがアクティブではありません)。', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + '会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。', + 'Rewinding does not affect files edited manually or via shell commands.': + '巻き戻しは、手動で編集されたファイルや shell コマンドで変更されたファイルには影響しません。', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + '圧縮されたターンへは巻き戻せません。より最近のターンをお試しください。', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'このターンではファイル復元できません(捕捉されたファイル変更がないか、現在のセッションより前のターンです)。', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}}、{{count}} 個のファイル)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}}、{{count}} 個のファイル)', + 'Failed to restore {{count}} file(s): {{files}}': + '{{count}} 個のファイルの復元に失敗しました:{{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'ファイルを復元できません:このターンはファイルチェックポイントが有効になる前に作成されました。', + 'No files needed to be restored.': '復元が必要なファイルはありません。', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ 移動 · Enter 選択 · Esc 戻る', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ 移動 · Enter 選択 · Esc キャンセル', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y 確認 · Esc/N 戻る', 'change the theme': 'テーマを変更', 'Select Theme': 'テーマを選択', Preview: 'プレビュー', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 187d7ba701c..b09eabc4fc4 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -125,6 +125,45 @@ export default { 'Renomear a conversa atual. --auto permite que o modelo rápido escolha um título.', 'Rewind conversation to a previous turn': 'Voltar a conversa para um turno anterior', + 'Rewind Conversation': 'Rebobinar conversa', + 'No user turns to rewind to.': 'Nenhum turno de usuário para rebobinar.', + 'Rewind to: ': 'Rebobinar para: ', + 'Restore code and conversation': 'Restaurar código e conversa', + 'Restore conversation only': 'Restaurar apenas a conversa', + 'Restore code only': 'Restaurar apenas o código', + 'Never mind': 'Deixa pra lá', + 'Computing file changes...': 'Calculando alterações de arquivo...', + 'Restoring...': 'Restaurando...', + 'Restored {{count}} file(s).': '{{count}} arquivo(s) restaurado(s).', + 'Failed to restore files: {{error}}': + 'Falha ao restaurar arquivos: {{error}}', + 'Rewind failed: {{error}}': 'Falha ao retroceder: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Não é possível retroceder a conversa: nenhum cliente de modelo ativo.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Código restaurado, mas a conversa não pôde ser retrocedida (sem cliente ativo).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Conversa retrocedida. Edite seu prompt e pressione Enter para continuar.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'O retrocesso não afeta arquivos editados manualmente ou por meio de comandos shell.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Não é possível retroceder para um turno que foi compactado. Tente um turno mais recente.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'A restauração de arquivos não está disponível para este turno (sem alterações capturadas, ou o turno é anterior à sessão atual).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} em {{count}} arquivo)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} em {{count}} arquivos)', + 'Failed to restore {{count}} file(s): {{files}}': + 'Falha ao restaurar {{count}} arquivo(s): {{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Não é possível restaurar arquivos: este turno foi criado antes do checkpoint de arquivos ser ativado.', + 'No files needed to be restored.': 'Nenhum arquivo precisou ser restaurado.', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ navegar · Enter selecionar · Esc voltar', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ navegar · Enter selecionar · Esc cancelar', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y confirmar · Esc/N voltar', 'change the theme': 'alterar o tema', 'Select Theme': 'Selecionar Tema', Preview: 'Visualizar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index a26e8b9fbf2..2d5296501fe 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -134,6 +134,45 @@ export default { 'Переименовать текущий разговор. --auto позволит быстрой модели выбрать заголовок.', 'Rewind conversation to a previous turn': 'Откатить разговор к предыдущему ходу', + 'Rewind Conversation': 'Перемотка разговора', + 'No user turns to rewind to.': 'Нет пользовательских ходов для перемотки.', + 'Rewind to: ': 'Перемотать к: ', + 'Restore code and conversation': 'Восстановить код и беседу', + 'Restore conversation only': 'Восстановить только беседу', + 'Restore code only': 'Восстановить только код', + 'Never mind': 'Неважно', + 'Computing file changes...': 'Вычисление изменений файлов...', + 'Restoring...': 'Восстановление...', + 'Restored {{count}} file(s).': 'Восстановлено файлов: {{count}}.', + 'Failed to restore files: {{error}}': + 'Не удалось восстановить файлы: {{error}}', + 'Rewind failed: {{error}}': 'Сбой отката: {{error}}', + 'Cannot rewind conversation: no active model client.': + 'Невозможно откатить разговор: нет активного клиента модели.', + 'Code restored, but conversation could not be rewound (no active client).': + 'Код восстановлен, но разговор не удалось откатить (нет активного клиента).', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + 'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Откат не затрагивает файлы, отредактированные вручную или с помощью shell-команд.', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + 'Не удаётся откатиться к сжатому ходу. Попробуйте более недавний ход.', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + 'Восстановление файлов недоступно для этого хода (нет записанных изменений или ход был до текущей сессии).', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}} в {{count}} файле)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}} в {{count}} файлах)', + 'Failed to restore {{count}} file(s): {{files}}': + 'Не удалось восстановить {{count}} файл(ов): {{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + 'Невозможно восстановить файлы: этот ход был создан до включения контрольных точек файлов.', + 'No files needed to be restored.': 'Файлы не нуждались в восстановлении.', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ навигация · Enter выбор · Esc назад', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ навигация · Enter выбор · Esc отмена', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y подтвердить · Esc/N назад', 'change the theme': 'Изменение темы', 'Select Theme': 'Выбор темы', Preview: 'Предпросмотр', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index e37e9467290..06aa4efc4e3 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -115,6 +115,44 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '重新命名目前的對話。--auto 會讓快速模型自動產生標題。', 'Rewind conversation to a previous turn': '將對話回退到先前的某一輪', + 'Rewind Conversation': '回退對話', + 'No user turns to rewind to.': '沒有可回退的使用者對話輪次。', + 'Rewind to: ': '回退到:', + 'Restore code and conversation': '恢復程式碼和對話', + 'Restore conversation only': '僅恢復對話', + 'Restore code only': '僅恢復程式碼', + 'Never mind': '算了', + 'Computing file changes...': '正在計算檔案變更...', + 'Restoring...': '正在恢復...', + 'Restored {{count}} file(s).': '已恢復 {{count}} 個檔案。', + 'Failed to restore files: {{error}}': '恢復檔案失敗:{{error}}', + 'Rewind failed: {{error}}': '回退失敗:{{error}}', + 'Cannot rewind conversation: no active model client.': + '無法回退對話:模型客戶端未啟用。', + 'Code restored, but conversation could not be rewound (no active client).': + '程式碼已恢復,但對話無法回退(模型客戶端未啟用)。', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + '對話已回退。修改提示後按 Enter 繼續。', + 'Rewinding does not affect files edited manually or via shell commands.': + '回退不會影響手動編輯或透過 shell 命令修改的檔案。', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + '無法回退到已被壓縮的輪次,請嘗試更近一些的輪次。', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + '該輪次無法還原檔案(沒有擷取到檔案變更,或該輪次屬於本次會話之前)。', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}},{{count}} 個檔案)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}},{{count}} 個檔案)', + 'Failed to restore {{count}} file(s): {{files}}': + '恢復 {{count}} 個檔案失敗:{{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + '無法恢復檔案:該輪對話建立時尚未啟用檔案檢查點功能。', + 'No files needed to be restored.': '沒有檔案需要恢復。', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ 導覽 · Enter 選取 · Esc 返回', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ 導覽 · Enter 選取 · Esc 取消', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y 確認 · Esc/N 返回', 'change the theme': '更改主題', 'Select Theme': '選擇主題', Preview: '預覽', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 66767546ab6..314b2240bc7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -126,6 +126,44 @@ export default { 'Rename the current conversation. --auto lets the fast model pick a title.': '重命名当前对话。--auto 会让快速模型自动生成标题。', 'Rewind conversation to a previous turn': '将对话回退到之前的某一轮', + 'Rewind Conversation': '回退对话', + 'No user turns to rewind to.': '没有可回退的用户对话轮次。', + 'Rewind to: ': '回退到:', + 'Restore code and conversation': '恢复代码和对话', + 'Restore conversation only': '仅恢复对话', + 'Restore code only': '仅恢复代码', + 'Never mind': '算了', + 'Computing file changes...': '正在计算文件变更...', + 'Restoring...': '正在恢复...', + 'Restored {{count}} file(s).': '已恢复 {{count}} 个文件。', + 'Failed to restore files: {{error}}': '恢复文件失败:{{error}}', + 'Rewind failed: {{error}}': '回退失败:{{error}}', + 'Cannot rewind conversation: no active model client.': + '无法回退对话:模型客户端未激活。', + 'Code restored, but conversation could not be rewound (no active client).': + '代码已恢复,但对话无法回退(模型客户端未激活)。', + 'Conversation rewound. Edit your prompt and press Enter to continue.': + '对话已回退。修改你的提示后按回车继续。', + 'Rewinding does not affect files edited manually or via shell commands.': + '回退不会影响手工编辑或通过 shell 命令修改的文件。', + 'Cannot rewind to a turn that was compressed. Try a more recent turn.': + '无法回退到已被压缩的轮次,请尝试更近一些的轮次。', + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).': + '该轮次无法恢复文件(没有捕获到文件变更,或该轮次属于本次会话之前)。', + '(+{{insertions}} -{{deletions}} in {{count}} file)': + '(+{{insertions}} -{{deletions}},{{count}} 个文件)', + '(+{{insertions}} -{{deletions}} in {{count}} files)': + '(+{{insertions}} -{{deletions}},{{count}} 个文件)', + 'Failed to restore {{count}} file(s): {{files}}': + '恢复 {{count}} 个文件失败:{{files}}', + 'Cannot restore files: this turn was created before file checkpointing was enabled.': + '无法恢复文件:该轮对话创建时尚未启用文件检查点功能。', + 'No files needed to be restored.': '没有文件需要恢复。', + '↑↓ to navigate · Enter to select · Esc to go back': + '↑↓ 导航 · Enter 选择 · Esc 返回', + '↑↓ to navigate · Enter to select · Esc to cancel': + '↑↓ 导航 · Enter 选择 · Esc 取消', + 'Enter/Y to confirm · Esc/N to go back': 'Enter/Y 确认 · Esc/N 返回', 'change the theme': '更改主题', 'Select Theme': '选择主题', Preview: '预览', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4e8a39120b6..8b9e5c511cd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,9 +25,11 @@ import { import { ConfigContext } from './contexts/ConfigContext.js'; import { type HistoryItem, + type HistoryItemUser, ToolCallStatus, type HistoryItemWithoutId, } from './types.js'; +import type { RestoreOption } from './components/RewindSelector.js'; import { MessageType, StreamingState } from './types.js'; import { type EditorType, @@ -2175,75 +2177,189 @@ export const AppContainer = (props: AppContainerProps) => { }, []); const handleRewindConfirm = useCallback( - (userItem: HistoryItem) => { - const geminiClient = config.getGeminiClient(); - if (!geminiClient) return; - - // 1. Compute values from current history BEFORE truncation - const originalHistory = historyManager.history; - const originalLength = originalHistory.length; - - let targetTurnIndex = 0; - for (const h of originalHistory) { - if (h.id === userItem.id) break; - if (isRealUserTurn(h)) targetTurnIndex++; - } + async (userItem: HistoryItem, option: RestoreOption) => { + try { + // For 'both', validate that conversation can be truncated BEFORE + // touching files — otherwise we'd roll back the workspace while + // the conversation stays at the newer state. + const needsConversation = + option === 'conversation' || option === 'both'; + const geminiClient = needsConversation + ? config.getGeminiClient() + : null; + let apiTruncateIndex = -1; + let conversationSkippedNoClient = false; + if (needsConversation) { + if (!geminiClient) { + if (option === 'conversation') { + historyManager.addItem( + { + type: 'error', + text: t( + 'Cannot rewind conversation: no active model client.', + ), + }, + Date.now(), + ); + return; + } + // 'both' with no client: skip conversation, still try files, + // and surface a warning after the restore output. + conversationSkippedNoClient = true; + } else { + apiTruncateIndex = computeApiTruncationIndex( + historyManager.history, + userItem.id, + geminiClient.getHistory(), + ); + if (apiTruncateIndex < 0) { + historyManager.addItem( + { + type: 'error', + text: t( + 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + ), + }, + Date.now(), + ); + if (option === 'both') { + // Abort file restore too — don't create inconsistent state + return; + } + return; + } + } + } - // 2. Compute API truncation point - const apiHistory = geminiClient.getHistory(); - const apiTruncateIndex = computeApiTruncationIndex( - originalHistory, - userItem.id, - apiHistory, - ); + // Restore code (files on disk). For 'code'-only, don't truncate + // the snapshot timeline — the conversation turns remain visible + // and their snapshots must stay available for future rewinds. + let fileRestoreMessage: string | undefined; + let fileRestoreError: string | undefined; + let hasRestoreFailure = false; + if (option === 'code' || option === 'both') { + const promptId = (userItem as HistoryItemUser).promptId; + if (promptId) { + try { + const truncateHistory = + option === 'both' && !!geminiClient && apiTruncateIndex >= 0; + const result = await config + .getFileHistoryService() + .rewind(promptId, truncateHistory); + if (result.filesChanged.length > 0) { + fileRestoreMessage = t('Restored {{count}} file(s).', { + count: String(result.filesChanged.length), + }); + } else if (result.filesFailed.length === 0) { + fileRestoreMessage = t('No files needed to be restored.'); + } + if (result.filesFailed.length > 0) { + hasRestoreFailure = true; + fileRestoreError = t( + 'Failed to restore {{count}} file(s): {{files}}', + { + count: String(result.filesFailed.length), + files: result.filesFailed + .map((f) => f.split('/').pop()) + .join(', '), + }, + ); + } + } catch (error) { + hasRestoreFailure = true; + fileRestoreError = t('Failed to restore files: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }); + } + } else { + hasRestoreFailure = true; + fileRestoreError = t( + 'Cannot restore files: this turn was created before file checkpointing was enabled.', + ); + } + } + + // Truncate conversation (already validated above). + // Skip if file restore had failures in "both" mode to avoid inconsistent state. + if ( + needsConversation && + geminiClient && + apiTruncateIndex >= 0 && + !(option === 'both' && hasRestoreFailure) + ) { + const originalHistory = historyManager.history; + const originalLength = originalHistory.length; + + let targetTurnIndex = 0; + for (const h of originalHistory) { + if (h.id === userItem.id) break; + if (isRealUserTurn(h)) targetTurnIndex++; + } + + geminiClient.truncateHistory(apiTruncateIndex); + + const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + historyManager.loadHistory(truncatedUi); + + refreshStatic(); + + if (userItem.type === 'user' && userItem.text) { + buffer.setText(userItem.text); + } + + historyManager.addItem( + { + type: 'info', + text: t( + 'Conversation rewound. Edit your prompt and press Enter to continue.', + ), + }, + Date.now(), + ); - // Abort if the target turn is unreachable (e.g., absorbed by compression) - if (apiTruncateIndex < 0) { + config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { + truncatedCount: originalLength - truncatedUi.length, + }); + } + + // Show file restore result after conversation truncation so the + // message isn't immediately removed by loadHistory. + if (fileRestoreMessage) { + historyManager.addItem( + { type: 'info', text: fileRestoreMessage }, + Date.now(), + ); + } + if (fileRestoreError) { + historyManager.addItem( + { type: 'error', text: fileRestoreError }, + Date.now(), + ); + } + if (conversationSkippedNoClient) { + historyManager.addItem( + { + type: 'info', + text: t( + 'Code restored, but conversation could not be rewound (no active client).', + ), + }, + Date.now(), + ); + } + } catch (error) { historyManager.addItem( { type: 'error', - text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + text: t('Rewind failed: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), }, Date.now(), ); + } finally { setIsRewindSelectorOpen(false); - return; - } - - // 3. Truncate API history to the target point. - // Do NOT strip thought parts — reasoning models (e.g. DeepSeek) require - // reasoning_content continuity across all turns in the conversation. - geminiClient.truncateHistory(apiTruncateIndex); - - // 4. Truncate UI history (keep everything before the target item) - const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); - historyManager.loadHistory(truncatedUi); - - // 5. Re-render the terminal - refreshStatic(); - - // 6. Pre-populate input with the original user text - if (userItem.type === 'user' && userItem.text) { - buffer.setText(userItem.text); } - - // 7. Add info message - historyManager.addItem( - { - type: 'info', - text: 'Conversation rewound. Edit your prompt and press Enter to continue.', - }, - Date.now(), - ); - - // 8. Record the rewind event — re-roots the parentUuid chain so - // rewound messages end up on a dead branch during resume. - config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { - truncatedCount: originalLength - truncatedUi.length, - }); - - // 9. Close the selector - setIsRewindSelectorOpen(false); }, [config, historyManager, refreshStatic, buffer], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 6e7cdd641e0..9960a680bb3 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -452,6 +452,8 @@ export const DialogManager = ({ history={uiState.history} onRewind={uiActions.handleRewindConfirm} onCancel={uiActions.closeRewindSelector} + fileCheckpointingEnabled={config.getFileCheckpointingEnabled()} + fileHistoryService={config.getFileHistoryService()} /> ); } diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index 5da006cadbe..b6e95d1efb9 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -4,27 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { Box, Text } from 'ink'; -import type { HistoryItem } from '../types.js'; +import type { HistoryItem, HistoryItemUser } from '../types.js'; import { theme } from '../semantic-colors.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { truncateText } from '../utils/sessionPickerUtils.js'; import { isRealUserTurn } from '../utils/historyMapping.js'; import { t } from '../../i18n/index.js'; +import type { FileHistoryService, DiffStats } from '@qwen-code/qwen-code-core'; + +export type RestoreOption = 'both' | 'conversation' | 'code' | 'cancel'; export interface RewindSelectorProps { history: HistoryItem[]; - onRewind: (userItem: HistoryItem) => void; + onRewind: (userItem: HistoryItem, option: RestoreOption) => void; onCancel: () => void; + fileCheckpointingEnabled: boolean; + fileHistoryService: FileHistoryService; } const MAX_VISIBLE_ITEMS = 7; -/** - * Extract user-type items from UI history for the rewind pick list. - */ function getUserTurns(history: HistoryItem[]): HistoryItem[] { return history.filter(isRealUserTurn); } @@ -91,26 +93,87 @@ function TurnItemView({ ); } +interface RestoreOptionItem { + key: RestoreOption; + label: string; + detail?: string; +} + +function getRestoreOptions( + diffStats: DiffStats | undefined, +): RestoreOptionItem[] { + const hasChanges = !!diffStats && diffStats.filesChanged.length > 0; + + const options: RestoreOptionItem[] = []; + + if (hasChanges) { + const fileCount = diffStats!.filesChanged.length; + const detail = t( + fileCount === 1 + ? '(+{{insertions}} -{{deletions}} in {{count}} file)' + : '(+{{insertions}} -{{deletions}} in {{count}} files)', + { + insertions: String(diffStats!.insertions), + deletions: String(diffStats!.deletions), + count: String(fileCount), + }, + ); + options.push({ + key: 'both', + label: t('Restore code and conversation'), + detail, + }); + } + + options.push({ + key: 'conversation', + label: t('Restore conversation only'), + }); + + if (hasChanges) { + options.push({ + key: 'code', + label: t('Restore code only'), + }); + } + + options.push({ + key: 'cancel', + label: t('Never mind'), + }); + + return options; +} + /** - * Two-phase rewind selector: + * Multi-phase rewind selector: * 1. Pick list — choose which user turn to rewind to - * 2. Confirm — confirm the rewind action + * 2. Restore options — choose what to restore (when file checkpointing enabled) + * 3. Confirm — Y/N confirm (when file checkpointing disabled, legacy fallback) */ export function RewindSelector({ history, onRewind, onCancel, + fileCheckpointingEnabled, + fileHistoryService, }: RewindSelectorProps) { const { columns: width, rows: height } = useTerminalSize(); const userTurns = useMemo(() => getUserTurns(history), [history]); const [selectedIndex, setSelectedIndex] = useState(userTurns.length - 1); + // Legacy confirm (when file checkpointing is off) const [confirmItem, setConfirmItem] = useState(null); + // Restore option phase (when file checkpointing is on) + const [restoreItem, setRestoreItem] = useState(null); + const [restoreOptionIndex, setRestoreOptionIndex] = useState(0); + const [diffStats, setDiffStats] = useState(undefined); + const [loadingDiff, setLoadingDiff] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); const boxWidth = width - 4; const maxVisibleItems = Math.min(MAX_VISIBLE_ITEMS, userTurns.length); - // Centered scroll offset const scrollOffset = useMemo(() => { if (userTurns.length <= maxVisibleItems) return 0; const halfVisible = Math.floor(maxVisibleItems / 2); @@ -127,10 +190,51 @@ export function RewindSelector({ const showScrollUp = scrollOffset > 0; const showScrollDown = scrollOffset + maxVisibleItems < userTurns.length; + const restoreOptions = useMemo( + () => getRestoreOptions(diffStats), + [diffStats], + ); + + // Load diff stats when entering restore option phase + useEffect(() => { + if (!restoreItem || !fileCheckpointingEnabled) return; + const promptId = (restoreItem as HistoryItemUser).promptId; + if (!promptId) { + setDiffStats(undefined); + setLoadingDiff(false); + return; + } + let cancelled = false; + setLoadingDiff(true); + fileHistoryService + .getDiffStats(promptId) + .then((stats) => { + if (!cancelled) { + setDiffStats(stats); + setRestoreOptionIndex(0); + setLoadingDiff(false); + } + }) + .catch(() => { + if (!cancelled) { + setDiffStats(undefined); + setRestoreOptionIndex(0); + setLoadingDiff(false); + } + }); + return () => { + cancelled = true; + }; + }, [restoreItem, fileCheckpointingEnabled, fileHistoryService]); + + // Legacy confirm handler const handleConfirmSelect = useCallback( (confirmed: boolean) => { if (confirmed && confirmItem) { - onRewind(confirmItem); + setIsRestoring(true); + Promise.resolve(onRewind(confirmItem, 'conversation')) + .catch(() => {}) + .finally(() => setIsRestoring(false)); } else { setConfirmItem(null); } @@ -151,7 +255,12 @@ export function RewindSelector({ if (name === 'return') { const selected = userTurns[selectedIndex]; if (selected) { - setConfirmItem(selected); + if (fileCheckpointingEnabled) { + setRestoreItem(selected); + setRestoreOptionIndex(0); + } else { + setConfirmItem(selected); + } } return; } @@ -166,12 +275,60 @@ export function RewindSelector({ return; } }, - { isActive: confirmItem === null }, + { isActive: confirmItem === null && restoreItem === null }, ); - // Confirm key handler + // Restore option key handler useKeypress( (key) => { + if (isRestoring) return; + + const { name, ctrl } = key; + + if (name === 'escape' || (ctrl && name === 'c')) { + setRestoreItem(null); + setDiffStats(undefined); + return; + } + + if (loadingDiff) return; + + if (name === 'return') { + const option = restoreOptions[restoreOptionIndex]; + if (option) { + if (option.key === 'cancel') { + setRestoreItem(null); + setDiffStats(undefined); + } else { + setIsRestoring(true); + Promise.resolve(onRewind(restoreItem!, option.key)) + .catch(() => {}) + .finally(() => setIsRestoring(false)); + } + } + return; + } + + if (name === 'up' || name === 'k') { + setRestoreOptionIndex((prev) => Math.max(0, prev - 1)); + return; + } + + if (name === 'down' || name === 'j') { + setRestoreOptionIndex((prev) => + Math.min(restoreOptions.length - 1, prev + 1), + ); + return; + } + }, + { isActive: restoreItem !== null }, + ); + + // Legacy confirm key handler + useKeypress( + (key) => { + if (isRestoring) return; + const { name, ctrl, sequence } = key; if (name === 'escape' || (ctrl && name === 'c')) { @@ -211,7 +368,109 @@ export function RewindSelector({ ); } - // Confirm phase + // Restore option phase + if (restoreItem) { + const promptPreview = truncateText( + restoreItem.text || '(empty)', + boxWidth - 10, + ); + return ( + + + + + {t('Rewind Conversation')} + + + + {'─'.repeat(boxWidth - 2)} + + + + {t('Rewind to: ')} + + {promptPreview} + + + {loadingDiff ? ( + + {t('Computing file changes...')} + + ) : isRestoring ? ( + {t('Restoring...')} + ) : ( + + {restoreOptions.map((option, idx) => { + const isSelected = idx === restoreOptionIndex; + const prefix = isSelected ? '› ' : ' '; + return ( + + + {prefix} + {option.label} + + {option.detail && ( + + {' '} + {option.detail} + + )} + + ); + })} + {restoreOptions.some( + (o) => o.key === 'code' || o.key === 'both', + ) ? ( + + + {t( + 'Rewinding does not affect files edited manually or via shell commands.', + )} + + + ) : ( + // No file-restore options were offered. Most likely either + // (a) the chosen turn has no captured edits, or (b) the + // turn predates this process / came from a resumed session + // whose snapshots were not rehydrated. Either way the + // "Restore code" path is not actionable for this turn — + // surface that explicitly so the user is not left + // wondering why the option is missing. + + + {t( + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).', + )} + + + )} + + )} + + + {'─'.repeat(boxWidth - 2)} + + + + {t('↑↓ to navigate · Enter to select · Esc to go back')} + + + + + ); + } + + // Legacy confirm phase (when file checkpointing is off) if (confirmItem) { const promptPreview = truncateText( confirmItem.text || '(empty)', diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c1f1c7b8e2d..8657e9b642b 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -13,6 +13,7 @@ import { type EditorType, type ApprovalMode } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; import type { AuthController } from '../auth/useAuth.js'; import type { HistoryItem } from '../types.js'; +import type { RestoreOption } from '../components/RewindSelector.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; export type HelpTab = 'general' | 'commands' | 'custom-commands'; @@ -98,7 +99,7 @@ export interface UIActions { // Rewind selector openRewindSelector: () => void; closeRewindSelector: () => void; - handleRewindConfirm: (userItem: HistoryItem) => void; + handleRewindConfirm: (userItem: HistoryItem, option: RestoreOption) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 6230b9009b1..323b5f8acf8 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -3140,6 +3140,7 @@ describe('useGeminiStream', () => { { type: MessageType.USER, text: rawQuery, + promptId: expect.any(String), }, userMessageTimestamp, ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8b203b1de49..d18421301f7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -796,7 +796,11 @@ export const useGeminiStream = ( // a duplicate `> …` line. Preprocessing (@/slash/shell) still runs. if (submitType !== SendMessageType.Cron) { const insertedId = addItem( - { type: MessageType.USER, text: trimmedQuery }, + { + type: MessageType.USER, + text: trimmedQuery, + promptId: prompt_id, + } as HistoryItemWithoutId, userMessageTimestamp, ); // Capture id+text so the cancel handler can verify identity, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index db2e3b9c83c..b29d114eb3e 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -95,6 +95,7 @@ export interface HistoryItemBase { export type HistoryItemUser = HistoryItemBase & { type: 'user'; text: string; + promptId?: string; }; export type HistoryItemGemini = HistoryItemBase & { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3680a2e3934..f5f75a9b1c0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -38,6 +38,7 @@ import { getRuntimeContentGenerator } from '../agents/runtime/agent-context.js'; // Services import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { FileHistoryService } from '../services/fileHistoryService.js'; import { type FileSystemService, StandardFileSystemService, @@ -488,6 +489,7 @@ export interface ConfigParameters { enableFuzzySearch?: boolean; }; checkpointing?: boolean; + fileCheckpointingEnabled?: boolean; proxy?: string; cwd: string; fileDiscoveryService?: FileDiscoveryService; @@ -753,6 +755,8 @@ export class Config { private sessionService: SessionService | undefined = undefined; private chatRecordingService: ChatRecordingService | undefined = undefined; private readonly checkpointing: boolean; + private readonly fileCheckpointingEnabled: boolean; + private fileHistoryService: FileHistoryService | undefined; private readonly proxy: string | undefined; private readonly cwd: string; private readonly explicitIncludeDirectories: string[]; @@ -909,6 +913,9 @@ export class Config { enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true, }; this.checkpointing = params.checkpointing ?? false; + this.fileCheckpointingEnabled = + params.fileCheckpointingEnabled ?? + (!params.sdkMode && (params.interactive ?? false)); this.proxy = params.proxy; this.cwd = params.cwd ?? process.cwd(); this.fileDiscoveryService = params.fileDiscoveryService ?? null; @@ -1727,6 +1734,7 @@ export class Config { // constructed via Object.create — those should clear their own // cache, not the parent's. this.getFileReadCache().clear(); + this.fileHistoryService = undefined; refreshSessionContext(this.sessionId); // The commit-attribution singleton accumulates per-file AI edits // and a session-scoped prompt counter — both stop being meaningful @@ -2579,6 +2587,21 @@ export class Config { return this.checkpointing; } + getFileCheckpointingEnabled(): boolean { + return this.fileCheckpointingEnabled; + } + + getFileHistoryService(): FileHistoryService { + if (!this.fileHistoryService) { + this.fileHistoryService = new FileHistoryService( + this.sessionId, + this.fileCheckpointingEnabled, + this.cwd, + ); + } + return this.fileHistoryService; + } + getProxy(): string | undefined { return normalizeProxyUrl(this.proxy); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ff9a76fc5e0..fbbd5111c93 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1224,6 +1224,14 @@ export class GeminiClient { this.sessionTurnCount++; + if (messageType === SendMessageType.UserQuery) { + try { + await this.config.getFileHistoryService().makeSnapshot(prompt_id); + } catch (e) { + debugLogger.error(`FileHistory: makeSnapshot failed: ${e}`); + } + } + if ( this.config.getMaxSessionTurns() > 0 && this.sessionTurnCount > this.config.getMaxSessionTurns() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f703e9cdff..8d368cb4745 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -134,6 +134,7 @@ export type { ToolSearchTool, ToolSearchParams } from './tools/tool-search.js'; export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export * from './services/fileDiscoveryService.js'; +export * from './services/fileHistoryService.js'; export * from './services/fileReadCache.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts new file mode 100644 index 00000000000..c7f2f7c0907 --- /dev/null +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -0,0 +1,592 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + mkdir, + mkdtemp, + rm, + stat, + writeFile, + readFile, +} from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const mockStorageDir = vi.hoisted(() => vi.fn()); +vi.mock('../config/storage.js', () => ({ + Storage: { getGlobalQwenDir: mockStorageDir }, +})); + +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { FileHistoryService } from './fileHistoryService.js'; + +describe('FileHistoryService', () => { + let projectDir: string; + let storageDir: string; + let service: FileHistoryService; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'fh-project-')); + storageDir = await mkdtemp(join(tmpdir(), 'fh-storage-')); + mockStorageDir.mockReturnValue(storageDir); + service = new FileHistoryService('test-session', true, projectDir); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + await rm(storageDir, { recursive: true, force: true }); + }); + + describe('disabled service', () => { + it('should no-op all operations when disabled', async () => { + const disabled = new FileHistoryService('s', false, projectDir); + await disabled.makeSnapshot('p1'); + await disabled.trackEdit('/foo'); + const result = await disabled.rewind('p1'); + expect(result).toEqual({ filesChanged: [], filesFailed: [] }); + expect(disabled.getSnapshots()).toEqual([]); + expect(await disabled.getDiffStats('p1')).toBeUndefined(); + }); + }); + + describe('trackEdit', () => { + it('should back up file before first edit in a snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(1); + const backups = snapshots[0].trackedFileBackups; + const key = Object.keys(backups)[0]; + expect(key).toBeDefined(); + expect(backups[key].version).toBe(1); + expect(backups[key].backupFileName).not.toBeNull(); + }); + + it('should skip if file already tracked in current snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await service.trackEdit(file); // second call + + const snapshots = service.getSnapshots(); + const backups = snapshots[0].trackedFileBackups; + expect(Object.keys(backups)).toHaveLength(1); + }); + + it('should record null backup for non-existent file', async () => { + const file = join(projectDir, 'nonexistent.txt'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + const snapshots = service.getSnapshots(); + const backups = snapshots[0].trackedFileBackups; + const key = Object.keys(backups)[0]; + expect(backups[key].backupFileName).toBeNull(); + }); + + // trackEdit must swallow createBackup failures so that the calling tool + // (edit / write_file) is never broken by file-history-side I/O errors. + it('does not throw and records nothing when createBackup fails', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + await service.makeSnapshot('p1'); + + // Replace the backup storage root with a regular file so the recursive + // `mkdir(dirname(backupPath))` inside `safeCopyFile` fails with + // ENOTDIR — a non-ENOENT error that propagates back into `trackEdit`'s + // catch. + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + + await expect(service.trackEdit(file)).resolves.toBeUndefined(); + expect(service.getSnapshots()[0].trackedFileBackups).toEqual({}); + }); + }); + + describe('makeSnapshot', () => { + it('should create snapshot with correct promptId', async () => { + await service.makeSnapshot('prompt-abc'); + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(1); + expect(snapshots[0].promptId).toBe('prompt-abc'); + }); + + it('should re-backup files that changed since last snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'v1'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Modify the file after tracking + await writeFile(file, 'v2-modified'); + + await service.makeSnapshot('p2'); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(2); + const p2Backups = snapshots[1].trackedFileBackups; + const key = Object.keys(p2Backups)[0]; + // Version should increment + expect(p2Backups[key].version).toBe(2); + }); + + it('should inherit version for unchanged files', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'unchanged'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await service.makeSnapshot('p2'); + + const snapshots = service.getSnapshots(); + const p1Key = Object.keys(snapshots[0].trackedFileBackups)[0]; + const p2Key = Object.keys(snapshots[1].trackedFileBackups)[0]; + // Same backup reference (version unchanged) + expect(snapshots[1].trackedFileBackups[p2Key].backupFileName).toBe( + snapshots[0].trackedFileBackups[p1Key].backupFileName, + ); + }); + + // When a per-file backup attempt throws inside makeSnapshot, the new + // snapshot must NOT silently inherit the previous snapshot's backup + // and present it as the captured state of this turn — that would + // make a later rewind restore older content while reporting success. + // Instead the snapshot records a `failed: true` marker so rewind + // surfaces the file via filesFailed and getDiffStats omits it. + it('marks per-file backup failures and does not silently inherit', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'p1-content'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Modify the file and break the backup target (replace storageDir + // with a regular file → ENOTDIR inside `safeCopyFile`'s recursive + // mkdir). The next makeSnapshot's per-file backup attempt throws. + await writeFile(file, 'p2-content'); + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + + await service.makeSnapshot('p2'); + + const p2Backups = service.getSnapshots()[1].trackedFileBackups; + const p2Backup = p2Backups['a.txt']; + expect(p2Backup).toBeDefined(); + expect(p2Backup.failed).toBe(true); + + // Rewind to p2 must report the file as failed, not silently + // restore p1-content as if it were the captured state of p2. + const result = await service.rewind('p2'); + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed).toContain(file); + }); + + // After a transient backup failure, the no-change optimization must NOT + // copy the failed entry forward into the next snapshot. If we did, the + // failed flag would stay sticky for as long as the file is unchanged, + // permanently poisoning rewind for that file even after the backup + // target recovers. + it('does not carry a failed marker forward when the file is unchanged', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'stable-content'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Break the backup target so p2's per-file backup throws; do NOT + // change the file content. + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + await service.makeSnapshot('p2'); + expect( + service.getSnapshots()[1].trackedFileBackups['a.txt']!.failed, + ).toBe(true); + + // Restore the backup target. The file is still unchanged. p3 must + // retry the backup (instead of copying p2's failed entry forward) and + // record a fresh non-failed entry. + await rm(storageDir, { recursive: true, force: true }); + await mkdir(storageDir, { recursive: true }); + + await service.makeSnapshot('p3'); + + const p3Backup = service.getSnapshots()[2].trackedFileBackups['a.txt']; + expect(p3Backup).toBeDefined(); + expect(p3Backup.failed).toBeFalsy(); + expect(p3Backup.backupFileName).not.toBeNull(); + + // Rewind to p3 succeeds (file is unchanged but the backup is now real). + const result = await service.rewind('p3'); + expect(result.filesFailed).toEqual([]); + }); + }); + + describe('rewind', () => { + it('should restore file to target snapshot state', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + const result = await service.rewind('p1'); + expect(result.filesChanged).toContain(file); + expect(result.filesFailed).toHaveLength(0); + + const content = await readFile(file, 'utf-8'); + expect(content).toBe('original'); + }); + + it('should delete file that did not exist at target snapshot', async () => { + await service.makeSnapshot('p1'); + + const file = join(projectDir, 'new-file.txt'); + await service.trackEdit(file); // non-existent → null backup + await writeFile(file, 'created'); + await service.makeSnapshot('p2'); + + const result = await service.rewind('p1'); + expect(result.filesChanged).toContain(file); + expect(existsSync(file)).toBe(false); + }); + + it('should return filesFailed when backup file is missing on disk', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + // Delete the backup file to simulate corruption + const snapshots = service.getSnapshots(); + const key = Object.keys(snapshots[0].trackedFileBackups)[0]; + const backupFileName = + snapshots[0].trackedFileBackups[key].backupFileName; + expect(backupFileName).not.toBeNull(); + const backupPath = join( + storageDir, + 'file-history', + 'test-session', + backupFileName!, + ); + await rm(backupPath, { force: true }); + + const result = await service.rewind('p1'); + expect(result.filesFailed.length).toBeGreaterThan(0); + }); + + // Edge case: both the on-disk backup and the working file have been + // removed externally. The target snapshot still expects the file to + // exist, so rewind must surface this as filesFailed instead of + // silently reporting success. + it('should report filesFailed when both backup and working file are gone', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + const snapshots = service.getSnapshots(); + const backupName = + snapshots[0].trackedFileBackups['a.txt']!.backupFileName!; + await rm(join(storageDir, 'file-history', 'test-session', backupName), { + force: true, + }); + await rm(file, { force: true }); + + const result = await service.rewind('p1'); + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed.length).toBeGreaterThan(0); + }); + + it('should preserve snapshot timeline when truncateHistory=false', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + + await service.rewind('p1', false); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].promptId).toBe('p1'); + expect(snapshots[1].promptId).toBe('p2'); + }); + + it('should truncate snapshot timeline when truncateHistory=true', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + await service.makeSnapshot('p3'); + + await service.rewind('p1', true); + + const snapshots = service.getSnapshots(); + expect(snapshots).toHaveLength(1); + expect(snapshots[0].promptId).toBe('p1'); + }); + + it('should throw when snapshot not found', async () => { + await service.makeSnapshot('p1'); + await expect(service.rewind('nonexistent')).rejects.toThrow( + 'The selected snapshot was not found', + ); + }); + + it('should not truncate snapshot timeline when restore has failures', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'modified'); + await service.makeSnapshot('p2'); + await service.makeSnapshot('p3'); + + // Corrupt the p1 backup so applySnapshot reports a failure. + const snapshots = service.getSnapshots(); + const key = Object.keys(snapshots[0].trackedFileBackups)[0]; + const backupFileName = + snapshots[0].trackedFileBackups[key].backupFileName!; + await rm( + join(storageDir, 'file-history', 'test-session', backupFileName), + { force: true }, + ); + + const result = await service.rewind('p1', true); + expect(result.filesFailed.length).toBeGreaterThan(0); + // Timeline must stay intact so the user can retry without losing state. + const after = service.getSnapshots(); + expect(after.map((s) => s.promptId)).toEqual(['p1', 'p2', 'p3']); + }); + + // checkOriginFileChanged short-circuits the restore when the file on + // disk already matches the target backup. Cover it explicitly so a + // future regression in stat/content comparison surfaces here instead + // of as silent extra writes (or skipped writes) to user files. + it('does not touch a file whose content matches the target snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'unchanged'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await service.makeSnapshot('p2'); + + // File content has not changed since p1 was tracked. Capture mtime so + // we can verify the file is not rewritten by the rewind. + const mtimeBefore = (await stat(file)).mtimeMs; + + const result = await service.rewind('p1'); + + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed).toEqual([]); + expect(await readFile(file, 'utf-8')).toBe('unchanged'); + expect((await stat(file)).mtimeMs).toBe(mtimeBefore); + }); + }); + + describe('trackEdit before any snapshot', () => { + it('should no-op when there is no most-recent snapshot', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'original'); + + await service.trackEdit(file); + + expect(service.getSnapshots()).toEqual([]); + }); + }); + + describe('restoreFromSnapshots', () => { + it('should rehydrate snapshots and derive trackedFiles', async () => { + const fresh = new FileHistoryService('test-session', true, projectDir); + const absPath = join(projectDir, 'a.txt'); + const externalPath = join(tmpdir(), 'fh-external-x.txt'); + + fresh.restoreFromSnapshots([ + { + promptId: 'p1', + trackedFileBackups: { + [absPath]: { + backupFileName: 'deadbeefcafebabe@v1', + version: 1, + backupTime: new Date(), + }, + [externalPath]: { + backupFileName: null, + version: 1, + backupTime: new Date(), + }, + }, + timestamp: new Date(), + }, + ]); + + const snapshots = fresh.getSnapshots(); + expect(snapshots).toHaveLength(1); + // Path under cwd should be shortened to a relative key. + expect(snapshots[0].trackedFileBackups['a.txt']).toBeDefined(); + // Path outside cwd should be preserved as-is. + expect(snapshots[0].trackedFileBackups[externalPath]).toBeDefined(); + }); + }); + + describe('snapshot eviction', () => { + const backupPath = (name: string) => + join(storageDir, 'file-history', 'test-session', name); + + it('should keep at most MAX_SNAPSHOTS (100) snapshots', async () => { + for (let i = 0; i < 105; i++) { + await service.makeSnapshot(`p${i}`); + } + const snapshots = service.getSnapshots(); + expect(snapshots.length).toBeLessThanOrEqual(100); + expect(snapshots[snapshots.length - 1].promptId).toBe('p104'); + }); + + it('should delete orphaned backup files on overflow', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'v0'); + + await service.makeSnapshot('p0'); + await service.trackEdit(file); // version 1, content 'v0' + + const evictedNames: string[] = []; + // Capture v1 from p0 before it gets evicted. + evictedNames.push( + service.getSnapshots()[0].trackedFileBackups['a.txt']!.backupFileName!, + ); + + // 104 more snapshots, each with new content → fresh backup per snapshot. + for (let i = 1; i < 105; i++) { + await writeFile(file, `v${i}`); + await service.makeSnapshot(`p${i}`); + if (i < 5) { + evictedNames.push( + service.getSnapshots()[i].trackedFileBackups['a.txt']! + .backupFileName!, + ); + } + } + + // p0..p4 (versions 1..5) were dropped by slice(-100); their backups should be gone. + for (const name of evictedNames) { + expect(existsSync(backupPath(name))).toBe(false); + } + // The surviving snapshots' backups must still exist. + const survivors = service.getSnapshots(); + for (const s of survivors) { + const bn = s.trackedFileBackups['a.txt']?.backupFileName; + if (bn) expect(existsSync(backupPath(bn))).toBe(true); + } + }); + + it('should preserve deduplicated backup files referenced by survivors', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'unchanged'); + + await service.makeSnapshot('p0'); + await service.trackEdit(file); + const sharedName = + service.getSnapshots()[0].trackedFileBackups['a.txt']!.backupFileName!; + + // Content never changes → makeSnapshot reuses the same backup reference. + for (let i = 1; i < 105; i++) { + await service.makeSnapshot(`p${i}`); + } + + // Same backupFileName is held by every survivor → must NOT be deleted. + expect(existsSync(backupPath(sharedName))).toBe(true); + }); + }); + + describe('rewind cleanup', () => { + it('should delete backups orphaned by truncation', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'v0'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + const v1 = + service.getSnapshots()[0].trackedFileBackups['a.txt']!.backupFileName!; + + await writeFile(file, 'v1'); + await service.makeSnapshot('p2'); + const v2 = + service.getSnapshots()[1].trackedFileBackups['a.txt']!.backupFileName!; + + await writeFile(file, 'v2'); + await service.makeSnapshot('p3'); + const v3 = + service.getSnapshots()[2].trackedFileBackups['a.txt']!.backupFileName!; + + await service.rewind('p1', true); + + const backupsDir = join(storageDir, 'file-history', 'test-session'); + // p1's backup is still referenced; p2 and p3's unique-version backups are gone. + expect(existsSync(join(backupsDir, v1))).toBe(true); + expect(existsSync(join(backupsDir, v2))).toBe(false); + expect(existsSync(join(backupsDir, v3))).toBe(false); + }); + }); + + describe('getDiffStats', () => { + it('should compute correct insertions and deletions', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'line1\nline2\nline3\n'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + await writeFile(file, 'line1\nmodified\nline3\nnewline\n'); + await service.makeSnapshot('p2'); + + const stats = await service.getDiffStats('p1'); + expect(stats).toBeDefined(); + expect(stats!.insertions).toBeGreaterThan(0); + expect(stats!.deletions).toBeGreaterThan(0); + expect(stats!.filesChanged).toContain(file); + }); + + it('should return undefined when disabled', async () => { + const disabled = new FileHistoryService('s', false, projectDir); + const stats = await disabled.getDiffStats('p1'); + expect(stats).toBeUndefined(); + }); + + it('should return undefined when snapshot not found', async () => { + const stats = await service.getDiffStats('nonexistent'); + expect(stats).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts new file mode 100644 index 00000000000..047a1efa580 --- /dev/null +++ b/packages/core/src/services/fileHistoryService.ts @@ -0,0 +1,704 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { Stats } from 'node:fs'; +import { + chmod, + copyFile, + mkdir, + readFile, + stat, + unlink, +} from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; +import { diffLines } from 'diff'; +import { Storage } from '../config/storage.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('FILE_HISTORY'); + +type BackupFileName = string | null; + +export interface FileHistoryBackup { + backupFileName: BackupFileName; + version: number; + backupTime: Date; + // Set when makeSnapshot's per-file backup attempt threw. Distinguishes + // "we have a confirmed backup of this file at this snapshot" from + // "we tried to capture this file at this snapshot but failed (so the + // attached backup, if any, is older than this turn)". Rewind / diff + // surface failed paths via filesFailed instead of silently restoring + // stale content as if it were current. + failed?: boolean; +} + +export interface FileHistorySnapshot { + promptId: string; + trackedFileBackups: Record; + timestamp: Date; +} + +export interface FileHistoryState { + snapshots: FileHistorySnapshot[]; + trackedFiles: Set; +} + +export interface DiffStats { + filesChanged: string[]; + insertions: number; + deletions: number; +} + +export interface RewindResult { + filesChanged: string[]; + filesFailed: string[]; +} + +const MAX_SNAPSHOTS = 100; +const FILE_HISTORY_DIR = 'file-history'; + +function isENOENT(e: unknown): boolean { + return ( + typeof e === 'object' && + e !== null && + 'code' in e && + (e as { code: string }).code === 'ENOENT' + ); +} + +async function pathExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +async function readFileOrNull(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch { + return null; + } +} + +function getBackupFileName(filePath: string, version: number): string { + const fileNameHash = createHash('sha256') + .update(filePath) + .digest('hex') + .slice(0, 16); + return `${fileNameHash}@v${version}`; +} + +function resolveBackupPath(backupFileName: string, sessionId: string): string { + return join( + Storage.getGlobalQwenDir(), + FILE_HISTORY_DIR, + sessionId, + backupFileName, + ); +} + +// Copy `src` to `dst`, creating the destination directory if it doesn't exist. +// Returns 'src-missing' if the source file is gone (e.g. deleted between an +// earlier `stat` and this call) so callers can distinguish that from a real +// I/O failure instead of treating every ENOENT as a missing target dir. +async function safeCopyFile( + src: string, + dst: string, +): Promise<'ok' | 'src-missing'> { + try { + await copyFile(src, dst); + return 'ok'; + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + if (!(await pathExists(src))) return 'src-missing'; + await mkdir(dirname(dst), { recursive: true }); + await copyFile(src, dst); + return 'ok'; + } +} + +async function createBackup( + filePath: string, + version: number, + sessionId: string, +): Promise { + const backupFileName = getBackupFileName(filePath, version); + const backupPath = resolveBackupPath(backupFileName, sessionId); + + let srcStats: Stats; + try { + srcStats = await stat(filePath); + } catch (e: unknown) { + if (isENOENT(e)) { + return { backupFileName: null, version, backupTime: new Date() }; + } + throw e; + } + + const result = await safeCopyFile(filePath, backupPath); + if (result === 'src-missing') { + return { backupFileName: null, version, backupTime: new Date() }; + } + + await chmod(backupPath, srcStats.mode); + + return { backupFileName, version, backupTime: new Date() }; +} + +async function restoreBackup( + filePath: string, + backupFileName: string, + sessionId: string, +): Promise { + const backupPath = resolveBackupPath(backupFileName, sessionId); + + let backupStats: Stats; + try { + backupStats = await stat(backupPath); + } catch (e: unknown) { + if (isENOENT(e)) { + debugLogger.error(`FileHistory: Backup file not found: ${backupPath}`); + return false; + } + throw e; + } + + const result = await safeCopyFile(backupPath, filePath); + if (result === 'src-missing') { + debugLogger.error( + `FileHistory: Backup file disappeared during restore: ${backupPath}`, + ); + return false; + } + + await chmod(filePath, backupStats.mode); + return true; +} + +async function checkOriginFileChanged( + originalFile: string, + backupFileName: string, + sessionId: string, + originalStatsHint?: Stats, +): Promise { + const backupPath = resolveBackupPath(backupFileName, sessionId); + + let originalStats: Stats | null = originalStatsHint ?? null; + if (!originalStats) { + try { + originalStats = await stat(originalFile); + } catch (e: unknown) { + if (!isENOENT(e)) return true; + } + } + + // Treat any failure to stat the backup (including ENOENT) as "changed" so + // callers attempt the restore: applySnapshot will surface the missing + // backup via restoreBackup → filesFailed, and makeSnapshot will create a + // fresh backup. The previous ENOENT branch silently reported "unchanged" + // when both the working file and the backup had been deleted, which let + // rewind report success even though the snapshot expected the file to + // exist. + let backupStats: Stats; + try { + backupStats = await stat(backupPath); + } catch { + return true; + } + + if (originalStats === null) return true; + + if ( + originalStats.mode !== backupStats.mode || + originalStats.size !== backupStats.size + ) { + return true; + } + + if (originalStats.mtimeMs < backupStats.mtimeMs) return false; + + try { + const [originalContent, backupContent] = await Promise.all([ + readFile(originalFile, 'utf-8'), + readFile(backupPath, 'utf-8'), + ]); + return originalContent !== backupContent; + } catch { + return true; + } +} + +async function computeDiffStatsForFile( + originalFile: string, + backupFileName: string | undefined, + sessionId: string, +): Promise { + const filesChanged: string[] = []; + let insertions = 0; + let deletions = 0; + + try { + const backupPath = backupFileName + ? resolveBackupPath(backupFileName, sessionId) + : undefined; + + const [originalContent, backupContent] = await Promise.all([ + readFileOrNull(originalFile), + backupPath ? readFileOrNull(backupPath) : null, + ]); + + if (originalContent === null && backupContent === null) { + return { filesChanged, insertions, deletions }; + } + + filesChanged.push(originalFile); + + const changes = diffLines(backupContent ?? '', originalContent ?? ''); + for (const c of changes) { + if (c.added) insertions += c.count || 0; + if (c.removed) deletions += c.count || 0; + } + } catch (error) { + debugLogger.error(`FileHistory: Error generating diffStats: ${error}`); + } + + return { filesChanged, insertions, deletions }; +} + +/** + * Tracks file edits made through the assistant's `edit` and `write_file` + * tools so `/rewind` can roll the workspace back to the state at a chosen + * turn boundary. + * + * Scope (intentional, mirrors upstream claude-code): only files touched + * via `edit` and `write_file` are tracked. Changes made via + * `run_shell_command` (`sed -i`, `cp`, `mv`, `rm`, `npm` scripts, `git` + * apply, etc.) and any out-of-tool manual edits are NOT captured, and + * `/rewind` cannot restore them. + */ +export class FileHistoryService { + private state: FileHistoryState = { + snapshots: [], + trackedFiles: new Set(), + }; + + private readonly sessionId: string; + private readonly enabled: boolean; + private readonly cwd: string; + + constructor(sessionId: string, enabled: boolean, cwd: string) { + this.sessionId = sessionId; + this.enabled = enabled; + this.cwd = cwd; + } + + isEnabled(): boolean { + return this.enabled; + } + + getSnapshots(): FileHistorySnapshot[] { + return this.state.snapshots; + } + + restoreFromSnapshots(snapshots: FileHistorySnapshot[]): void { + const trackedFiles = new Set(); + const migrated: FileHistorySnapshot[] = []; + for (const snapshot of snapshots) { + const trackedFileBackups: Record = {}; + for (const [p, backup] of Object.entries(snapshot.trackedFileBackups)) { + const trackingPath = this.maybeShortenFilePath(p); + trackedFiles.add(trackingPath); + trackedFileBackups[trackingPath] = backup; + } + migrated.push({ ...snapshot, trackedFileBackups }); + } + this.state = { + snapshots: migrated, + trackedFiles, + }; + } + + async trackEdit(filePath: string): Promise { + if (!this.enabled) return; + + const trackingPath = this.maybeShortenFilePath(filePath); + const mostRecent = this.state.snapshots.at(-1); + + if (!mostRecent) { + debugLogger.error('FileHistory: Missing most recent snapshot'); + return; + } + + if (mostRecent.trackedFileBackups[trackingPath]) { + return; + } + + const maxVersion = this.getMaxVersion(trackingPath); + + let backup: FileHistoryBackup; + try { + backup = await createBackup(filePath, maxVersion + 1, this.sessionId); + } catch (error) { + debugLogger.error(`FileHistory: trackEdit failed: ${error}`); + return; + } + + // Re-check after async backup — concurrent calls write the same + // deterministic path, so the second overwrites the first harmlessly. + if (!mostRecent.trackedFileBackups[trackingPath]) { + mostRecent.trackedFileBackups[trackingPath] = backup; + this.state.trackedFiles.add(trackingPath); + } + + debugLogger.debug(`FileHistory: Tracked file modification for ${filePath}`); + } + + async makeSnapshot(promptId: string): Promise { + if (!this.enabled) return; + + const trackedFileBackups: Record = {}; + const mostRecent = this.state.snapshots.at(-1); + + if (mostRecent) { + await Promise.all( + Array.from(this.state.trackedFiles, async (trackingPath) => { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const latestBackup = mostRecent.trackedFileBackups[trackingPath]; + const nextVersion = this.getMaxVersion(trackingPath) + 1; + + let fileStats: Stats | undefined; + try { + fileStats = await stat(filePath); + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + } + + if (!fileStats) { + trackedFileBackups[trackingPath] = { + backupFileName: null, + version: nextVersion, + backupTime: new Date(), + }; + return; + } + + if ( + latestBackup && + !latestBackup.failed && + latestBackup.backupFileName !== null && + !(await checkOriginFileChanged( + filePath, + latestBackup.backupFileName, + this.sessionId, + fileStats, + )) + ) { + // The previous snapshot has a confirmed (non-failed) backup of + // an unchanged file — reuse it. We must NOT reach this branch + // when `latestBackup.failed` is set: copying that entry forward + // would carry the `failed` flag into every subsequent snapshot + // for as long as the file stays unchanged, permanently + // poisoning rewind for that file. Instead we fall through and + // retry `createBackup`, which either heals (transient I/O + // recovered) or honestly records another failed entry. + trackedFileBackups[trackingPath] = latestBackup; + return; + } + + trackedFileBackups[trackingPath] = await createBackup( + filePath, + nextVersion, + this.sessionId, + ); + } catch (error) { + debugLogger.error( + `FileHistory: Failed to backup file ${trackingPath}: ${error}`, + ); + // Record the failure rather than letting the inheritance loop + // silently copy the previous snapshot's backup — that would + // make a rewind to this snapshot restore the file to its + // pre-failure content as if it were the captured state of + // this turn. + const previous = mostRecent?.trackedFileBackups[trackingPath]; + trackedFileBackups[trackingPath] = { + backupFileName: previous?.backupFileName ?? null, + version: this.getMaxVersion(trackingPath) + 1, + backupTime: new Date(), + failed: true, + }; + } + }), + ); + } + + for (const trackingPath of this.state.trackedFiles) { + if (trackingPath in trackedFileBackups) continue; + const inherited = mostRecent?.trackedFileBackups[trackingPath]; + if (inherited) trackedFileBackups[trackingPath] = inherited; + } + + const newSnapshot: FileHistorySnapshot = { + promptId, + trackedFileBackups, + timestamp: new Date(), + }; + + this.state.snapshots.push(newSnapshot); + if (this.state.snapshots.length > MAX_SNAPSHOTS) { + const overflow = this.state.snapshots.length - MAX_SNAPSHOTS; + const removed = this.state.snapshots.slice(0, overflow); + this.state.snapshots = this.state.snapshots.slice(overflow); + await this.cleanupOrphanedBackups(removed); + } + + debugLogger.debug( + `FileHistory: Added snapshot for ${promptId}, tracking ${this.state.trackedFiles.size} files`, + ); + } + + async rewind( + promptId: string, + truncateHistory = true, + ): Promise { + if (!this.enabled) return { filesChanged: [], filesFailed: [] }; + + const targetSnapshot = this.findSnapshot(promptId); + if (!targetSnapshot) { + throw new Error('The selected snapshot was not found'); + } + + debugLogger.debug(`FileHistory: Rewinding to snapshot for ${promptId}`); + const result = await this.applySnapshot(targetSnapshot); + + if (truncateHistory && result.filesFailed.length === 0) { + const targetIdx = this.state.snapshots.indexOf(targetSnapshot); + if (targetIdx >= 0) { + const removed = this.state.snapshots.slice(targetIdx + 1); + this.state.snapshots = this.state.snapshots.slice(0, targetIdx + 1); + this.state.trackedFiles = new Set( + this.state.snapshots.flatMap((s) => + Object.keys(s.trackedFileBackups), + ), + ); + await this.cleanupOrphanedBackups(removed); + } + } + + debugLogger.debug(`FileHistory: Finished rewinding to ${promptId}`); + return result; + } + + async getDiffStats(promptId: string): Promise { + if (!this.enabled) return undefined; + + const targetSnapshot = this.findSnapshot(promptId); + if (!targetSnapshot) return undefined; + + const results = await Promise.all( + Array.from(this.state.trackedFiles, async (trackingPath) => { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + + // The backup attempt failed at the target snapshot; we cannot + // produce a meaningful diff against a content we never captured, + // so omit this file from the preview rather than show a diff + // versus an older inherited backup. + if (targetBackup?.failed) return null; + + const backupFileName: BackupFileName | undefined = targetBackup + ? targetBackup.backupFileName + : this.getBackupFileNameFirstVersion(trackingPath); + + if (backupFileName === undefined) return null; + + const stats = await computeDiffStatsForFile( + filePath, + backupFileName === null ? undefined : backupFileName, + this.sessionId, + ); + if (stats?.insertions || stats?.deletions) { + return { filePath, stats }; + } + if (backupFileName === null && (await pathExists(filePath))) { + return { filePath, stats }; + } + return null; + } catch (error) { + debugLogger.error( + `FileHistory: Error computing diff stats: ${error}`, + ); + return null; + } + }), + ); + + const filesChanged: string[] = []; + let insertions = 0; + let deletions = 0; + for (const r of results) { + if (!r) continue; + filesChanged.push(r.filePath); + insertions += r.stats?.insertions || 0; + deletions += r.stats?.deletions || 0; + } + return { filesChanged, insertions, deletions }; + } + + private findSnapshot(promptId: string): FileHistorySnapshot | undefined { + for (let i = this.state.snapshots.length - 1; i >= 0; i--) { + if (this.state.snapshots[i]!.promptId === promptId) { + return this.state.snapshots[i]; + } + } + return undefined; + } + + private async applySnapshot( + targetSnapshot: FileHistorySnapshot, + ): Promise { + const filesChanged: string[] = []; + const filesFailed: string[] = []; + for (const trackingPath of this.state.trackedFiles) { + try { + const filePath = this.maybeExpandFilePath(trackingPath); + const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + + // makeSnapshot couldn't capture this file at the target turn. + // Surface it as failed instead of restoring the carried-over + // (older) backup as if it were the captured state. + if (targetBackup?.failed) { + filesFailed.push(filePath); + continue; + } + + const backupFileName: BackupFileName | undefined = targetBackup + ? targetBackup.backupFileName + : this.getBackupFileNameFirstVersion(trackingPath); + + if (backupFileName === undefined) { + debugLogger.error( + 'FileHistory: Error finding the backup file to apply', + ); + filesFailed.push(filePath); + continue; + } + + if (backupFileName === null) { + try { + await unlink(filePath); + debugLogger.debug(`FileHistory: Deleted ${filePath}`); + filesChanged.push(filePath); + } catch (e: unknown) { + if (!isENOENT(e)) throw e; + } + continue; + } + + if ( + await checkOriginFileChanged(filePath, backupFileName, this.sessionId) + ) { + const restored = await restoreBackup( + filePath, + backupFileName, + this.sessionId, + ); + if (restored) { + debugLogger.debug( + `FileHistory: Restored ${filePath} from ${backupFileName}`, + ); + filesChanged.push(filePath); + } else { + filesFailed.push(filePath); + } + } + } catch (error) { + debugLogger.error( + `FileHistory: Error restoring file ${trackingPath}: ${error}`, + ); + filesFailed.push(this.maybeExpandFilePath(trackingPath)); + } + } + return { filesChanged, filesFailed }; + } + + private getBackupFileNameFirstVersion( + trackingPath: string, + ): BackupFileName | undefined { + for (const snapshot of this.state.snapshots) { + const backup = snapshot.trackedFileBackups[trackingPath]; + if (backup !== undefined && backup.version === 1) { + return backup.backupFileName; + } + } + return undefined; + } + + private getMaxVersion(trackingPath: string): number { + let maxVersion = 0; + for (const snapshot of this.state.snapshots) { + const existing = snapshot.trackedFileBackups[trackingPath]; + if (existing && existing.version > maxVersion) { + maxVersion = existing.version; + } + } + return maxVersion; + } + + // Best-effort: delete on-disk backup files referenced only by `removedSnapshots` + // and not by any surviving snapshot. Backup files are content-deduplicated + // across snapshots (see makeSnapshot's reuse of latestBackup), so we must + // skip any name still in the live set. + private async cleanupOrphanedBackups( + removedSnapshots: FileHistorySnapshot[], + ): Promise { + const liveBackups = new Set(); + for (const s of this.state.snapshots) { + for (const b of Object.values(s.trackedFileBackups)) { + if (b.backupFileName !== null) liveBackups.add(b.backupFileName); + } + } + + const toDelete = new Set(); + for (const s of removedSnapshots) { + for (const b of Object.values(s.trackedFileBackups)) { + if (b.backupFileName !== null && !liveBackups.has(b.backupFileName)) { + toDelete.add(b.backupFileName); + } + } + } + + await Promise.all( + Array.from(toDelete, async (name) => { + try { + await unlink(resolveBackupPath(name, this.sessionId)); + } catch (e: unknown) { + if (!isENOENT(e)) { + debugLogger.error(`FileHistory: cleanup failed for ${name}: ${e}`); + } + } + }), + ); + } + + private maybeShortenFilePath(filePath: string): string { + if (!isAbsolute(filePath)) return filePath; + if (filePath.startsWith(this.cwd + sep) || filePath === this.cwd) { + return relative(this.cwd, filePath); + } + return filePath; + } + + private maybeExpandFilePath(filePath: string): string { + if (isAbsolute(filePath)) return filePath; + return join(this.cwd, filePath); + } +} diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index d99561dbbf3..4975a5a98da 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -40,6 +40,7 @@ describe('EditTool', () => { let geminiClient: any; let baseLlmClient: any; let fileReadCache: FileReadCache; + let mockFileHistoryService: { trackEdit: ReturnType }; let fsService: StandardFileSystemService; beforeEach(() => { @@ -48,6 +49,7 @@ describe('EditTool', () => { rootDir = path.join(tempDir, 'root'); fs.mkdirSync(rootDir); fileReadCache = new FileReadCache(); + mockFileHistoryService = { trackEdit: vi.fn() }; fsService = new StandardFileSystemService(); geminiClient = { @@ -86,6 +88,7 @@ describe('EditTool', () => { getDefaultFileEncoding: vi.fn().mockReturnValue('utf-8'), getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: vi.fn().mockReturnValue(false), + getFileHistoryService: () => mockFileHistoryService, } as unknown as Config; // Reset mocks before each test @@ -497,12 +500,40 @@ describe('EditTool', () => { /Showing lines \d+-\d+ of \d+ from the edited file:/, ); expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); const display = result.returnDisplay as FileDiff; expect(display.fileDiff).toMatch(initialContent); expect(display.fileDiff).toMatch(newContent); expect(display.fileName).toBe(testFile); }); + // trackEdit is best-effort: a FileHistoryService failure (disk full, + // permissions, corrupted state) must never break the edit tool. + it('completes the edit even when trackEdit throws', async () => { + const initialContent = 'This is some old text.'; + const newContent = 'This is some new text.'; + fs.writeFileSync(filePath, initialContent, 'utf8'); + seedPriorRead(filePath); + mockFileHistoryService.trackEdit.mockRejectedValueOnce( + new Error('disk full'), + ); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); + expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); + expect(result.llmContent).toMatch( + /Showing lines \d+-\d+ of \d+ from the edited file:/, + ); + }); + // The Edit tool feeds the commit-attribution singleton on success so // commit notes can later report per-file AI/human ratios. Service- // level tests for `recordEdit` already exist; these guard against diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 7ec717829e4..553dc4cdba8 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -539,6 +539,14 @@ class EditToolInvocation implements ToolInvocation { // edit. this.ensureParentDirectoriesExist(this.params.file_path); + try { + await this.config + .getFileHistoryService() + .trackEdit(this.params.file_path); + } catch { + // File history is best-effort; never block core tool operations. + } + // For new files, apply default file encoding setting // For existing files, preserve the original encoding (BOM and charset) if (editData.isNewFile) { diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index 84ea075911d..92b1412c6d7 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -9,13 +9,9 @@ import type { ToolConfirmationPayload, ToolExecuteConfirmationDetails, ToolResult, - - ToolConfirmationOutcome} from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind + ToolConfirmationOutcome, } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { Config } from '../config/config.js'; import type { PermissionDecision } from '../permissions/types.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index a5bc325c71a..5c374c15eb2 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -40,6 +40,7 @@ let mockGeminiClientInstance: Mocked; // Mock Config const fsService = new StandardFileSystemService(); const fileReadCache = new FileReadCache(); +const mockFileHistoryService = { trackEdit: vi.fn() }; const mockConfigInternal = { getTargetDir: () => rootDir, getProjectRoot: () => rootDir, @@ -72,6 +73,7 @@ const mockConfigInternal = { getDefaultFileEncoding: () => 'utf-8', getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: () => false, + getFileHistoryService: () => mockFileHistoryService, }; const mockConfig = mockConfigInternal as unknown as Config; @@ -351,6 +353,7 @@ describe('WriteFileTool', () => { expect(result.llmContent).toMatch( /Successfully created and wrote to new file/, ); + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); expect(fs.existsSync(filePath)).toBe(true); const { content: writtenContent } = await fsService.readTextFile({ path: filePath, @@ -365,6 +368,41 @@ describe('WriteFileTool', () => { ); }); + // trackEdit is best-effort: a FileHistoryService failure (disk full, + // permissions, corrupted state) must never break the write_file tool. + it('completes the write even when trackEdit throws', async () => { + const filePath = path.join(rootDir, 'write_when_trackedit_fails.txt'); + const proposedContent = 'Content that survives trackEdit failure.'; + mockFileHistoryService.trackEdit.mockRejectedValueOnce( + new Error('disk full'), + ); + + const params = { file_path: filePath, content: proposedContent }; + const invocation = tool.build(params); + + const confirmDetails = + await invocation.getConfirmationDetails(abortSignal); + if ( + typeof confirmDetails === 'object' && + 'onConfirm' in confirmDetails && + confirmDetails.onConfirm + ) { + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); + } + + const result = await invocation.execute(abortSignal); + + expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath); + expect(result.llmContent).toMatch( + /Successfully created and wrote to new file/, + ); + expect(fs.existsSync(filePath)).toBe(true); + const { content: writtenContent } = await fsService.readTextFile({ + path: filePath, + }); + expect(writtenContent).toBe(proposedContent); + }); + it('should overwrite an existing file and return diff', async () => { const filePath = path.join(rootDir, 'execute_existing_file.txt'); const initialContent = 'Initial content for execute.'; diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 751c5372ef2..ec9cfef75d0 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -430,6 +430,12 @@ class WriteFileToolInvocation extends BaseToolInvocation< fs.mkdirSync(dirName, { recursive: true }); } + try { + await this.config.getFileHistoryService().trackEdit(file_path); + } catch { + // File history is best-effort; never block core tool operations. + } + try { await this.config.getFileSystemService().writeTextFile({ path: file_path,