@anionex/dsh-turn-rewind 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,6 +78,7 @@ interface SlotsLike {
78
78
  readonly id?: string
79
79
  readonly key?: string
80
80
  readonly order?: number
81
+ readonly label?: string | (() => string)
81
82
  readonly locale?: string
82
83
  readonly inject?: () => I
83
84
  },
@@ -85,10 +86,21 @@ interface SlotsLike {
85
86
  ): () => void
86
87
  }
87
88
 
89
+ /** Immutable locale state published by `@deepseek-ai/dsh-client-locale`. */
90
+ interface LocaleSnapshotLike {
91
+ /** Active locale id, a BCP 47-style tag such as `zh` or `en`. */
92
+ readonly active: string
93
+ }
94
+
95
+ /** The part of the Host locale service this plugin reads. */
96
+ interface LocaleRuntimeLike {
97
+ getSnapshot(): LocaleSnapshotLike
98
+ }
99
+
88
100
  interface ClientContextLike {
89
101
  readonly slots: SlotsLike
90
102
  readonly sessions: {
91
- open(sessionId: string): void
103
+ open?(sessionId: string): void
92
104
  scope(sessionId: string): unknown | undefined
93
105
  }
94
106
  readonly conversation: {
@@ -96,10 +108,16 @@ interface ClientContextLike {
96
108
  for(scope: unknown): { setDraft(text: string): void }
97
109
  }
98
110
  }
99
- readonly settingsScope?: {
100
- bind<T>(spec: { readonly namespace: string }): SettingsScopeLike<T>
101
- }
111
+ get?(name: string): unknown
102
112
  effect(setup: () => (() => void), label?: string): unknown
113
+ /**
114
+ * Cordis's un-injected service read: the service value, or `undefined` when
115
+ * the profile mounts none. Optional because older clients and the test
116
+ * doubles expose no such method.
117
+ */
118
+ get?(name: 'locale'): LocaleRuntimeLike | undefined
119
+ /** Cordis's event subscription; the result is a disposer when it is one. */
120
+ on?(name: 'locale/change', listener: (snapshot: LocaleSnapshotLike) => void): unknown
103
121
  }
104
122
 
105
123
  type RewindMode = 'both' | 'code' | 'messages'
@@ -156,7 +174,7 @@ export interface TurnRewindSettingsValue {
156
174
  readonly turnCheckpointTrust: 'fast' | 'strict'
157
175
  }
158
176
 
159
- /** Browser mirror of one settings namespace, as bound by `ctx.settingsScope`. */
177
+ /** Browser settings transport shared by the old namespace and new Config form. */
160
178
  export interface SettingsScopeLike<T> {
161
179
  getSnapshot(): SettingsScopeSnapshotLike<T>
162
180
  subscribe(listener: () => void): () => void
@@ -271,6 +289,569 @@ const styles = `
271
289
  .dcl-trs-storage{margin:0;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-size:12px;overflow-wrap:anywhere}
272
290
  `
273
291
 
292
+ /** UI languages this plugin ships copy for. */
293
+ type UiLocale = 'zh' | 'en'
294
+
295
+ /** Label and help text for one numeric settings field. */
296
+ interface FieldText {
297
+ readonly label: string
298
+ readonly description: string
299
+ }
300
+
301
+ /** Every user-facing string the rewind surface renders, in one language. */
302
+ interface RewindText {
303
+ readonly rewindTooltip: string
304
+ readonly dialogClose: string
305
+ readonly dialogDescription: string
306
+ readonly cancel: string
307
+ readonly applying: string
308
+ readonly done: string
309
+ readonly actionRestoreAndRestart: string
310
+ readonly actionRestoreFiles: string
311
+ readonly actionMessagesOnly: string
312
+ readonly checkingFiles: string
313
+ readonly previewPending: string
314
+ readonly previewMissing: string
315
+ readonly modeBothTitle: string
316
+ readonly modeBothDescription: string
317
+ readonly modeCodeTitle: string
318
+ readonly modeCodeDescription: string
319
+ readonly modeMessagesTitle: string
320
+ readonly modeMessagesDescription: string
321
+ readonly filesUnchanged: string
322
+ readonly filesToRestore: (count: number) => string
323
+ readonly summaryBoth: string
324
+ readonly summaryCode: string
325
+ readonly summaryMessages: string
326
+ readonly sharedBlocked: string
327
+ readonly branchChanged: string
328
+ readonly headAdvanced: string
329
+ readonly driftBlocked: string
330
+ readonly planMissing: string
331
+ readonly stale: string
332
+ readonly nothingToRestore: string
333
+ readonly loadingAllFiles: string
334
+ readonly viewAllFiles: (count: number) => string
335
+ readonly backupNote: string
336
+ readonly recheck: string
337
+ readonly completedMessages: string
338
+ readonly completedCode: string
339
+ readonly completedBoth: string
340
+ readonly openFailed: (reason: string) => string
341
+ readonly restoredButOpenFailed: (reason: string) => string
342
+ readonly modeMismatch: (mode: string) => string
343
+ readonly listChangedWhileExpanding: string
344
+ readonly incompleteFileList: string
345
+ readonly fileKinds: Readonly<Record<ChangeKind, string>>
346
+ /** Keyed by the Host error code, which stays the same in every language. */
347
+ readonly errors: Readonly<Record<string, string>>
348
+ readonly captureSnapshotLimit: string
349
+ readonly captureFileLimit: string
350
+ readonly captureSkipped: (count: number, example: string | undefined) => string
351
+ readonly skipTimeout: string
352
+ readonly skipDisabled: string
353
+ readonly skipNewContentLimit: string
354
+ readonly skipOther: (reason: string) => string
355
+ readonly failureNotGitRepository: string
356
+ readonly failureGitStatus: (message: string) => string
357
+ readonly failureSizeOrCount: string
358
+ readonly failureUnsupportedType: string
359
+ readonly failureIgnoreFileInvalid: string
360
+ readonly failureInvalidPath: string
361
+ readonly failureOther: (message: string) => string
362
+ readonly settingsCardTitle: string
363
+ readonly settingsCardDescription: string
364
+ readonly autoCheckpointSection: string
365
+ readonly settingsLoading: string
366
+ readonly settingsUnavailable: string
367
+ readonly settingsReadOnly: string
368
+ readonly autoCheckpointLabel: string
369
+ readonly autoCheckpointDescription: string
370
+ readonly trustLabel: string
371
+ readonly trustDescription: string
372
+ readonly overridden: string
373
+ readonly resetToDefault: string
374
+ readonly positiveInteger: (field: string) => string
375
+ readonly storageDir: (directory: string) => string
376
+ readonly manageSection: string
377
+ readonly refreshing: string
378
+ readonly refresh: string
379
+ readonly clearing: string
380
+ readonly confirmClearAll: string
381
+ readonly clearAll: string
382
+ readonly manageLoading: string
383
+ readonly manageTotal: (workspaces: number, points: number, size: string) => string
384
+ readonly workspacePoints: (count: number) => string
385
+ readonly pendingRecoveries: (count: number) => string
386
+ readonly expand: string
387
+ readonly collapse: string
388
+ readonly clearWorkspace: string
389
+ readonly fileCount: (count: number) => string
390
+ readonly deleting: string
391
+ readonly remove: string
392
+ readonly noCheckpoints: string
393
+ readonly clearedAll: (deleted: number, retained: number, failures: number) => string
394
+ readonly cleared: (deleted: number, retained: number) => string
395
+ readonly checkpointModes: Readonly<Record<TurnRewindSettingsValue['turnCheckpointMode'], string>>
396
+ readonly trustOptions: Readonly<Record<TurnRewindSettingsValue['turnCheckpointTrust'], string>>
397
+ /** Keyed by the durable restore-point kind, which is not a display value. */
398
+ readonly pointKinds: Readonly<Record<string, string>>
399
+ readonly numberFields: Readonly<Record<NumberSettingsField, FieldText>>
400
+ readonly manageMissingWorkspaces: string
401
+ readonly manageMissingRestorePoints: string
402
+ readonly clearMissingReports: string
403
+ readonly previewMissingChanges: string
404
+ readonly previewMissingActiveSessionIds: string
405
+ readonly invalidField: (name: string) => string
406
+ readonly invalidObject: string
407
+ readonly unknownStatus: (status: string) => string
408
+ readonly sessionNotReady: string
409
+ readonly unparsableResponse: (status: string) => string
410
+ readonly requestFailed: (status: string) => string
411
+ readonly emptyResponse: string
412
+ }
413
+
414
+ /** Chinese copy — the language this plugin shipped in before English existed. */
415
+ const ZH: RewindText = {
416
+ rewindTooltip: '恢复到发送这条消息之前',
417
+ dialogClose: '关闭',
418
+ dialogDescription: '查看恢复的文件,选择适合你的回退方式。当前会话不受影响。',
419
+ cancel: '取消',
420
+ applying: '正在恢复…',
421
+ done: '已完成',
422
+ actionRestoreAndRestart: '恢复并从这里继续',
423
+ actionRestoreFiles: '恢复文件',
424
+ actionMessagesOnly: '只回溯消息',
425
+ checkingFiles: '正在检查可以恢复的项目文件…',
426
+ previewPending: '这条消息发送前的文件还在保存,请稍后再试。',
427
+ previewMissing: '没有保存这条消息发送前的文件。可能是当时还没启用回退功能、记录已超过保留期限,或已关闭自动检查点。仍可只回溯消息。',
428
+ modeBothTitle: '恢复文件并从这里继续',
429
+ modeBothDescription: '创建一个从这里开始的新会话(当前对话会保留)',
430
+ modeCodeTitle: '只恢复文件',
431
+ modeCodeDescription: '恢复这条消息发送前的文件,当前对话保持不变。',
432
+ modeMessagesTitle: '只回溯消息(不动文件)',
433
+ modeMessagesDescription: '创建一个从这里开始的新会话,项目文件保持当前状态。',
434
+ filesUnchanged: '项目文件保持不变',
435
+ filesToRestore: count => `将恢复 ${String(count)} 个文件`,
436
+ summaryBoth: '恢复后在新对话里继续',
437
+ summaryCode: '当前对话保持不变',
438
+ summaryMessages: '仅创建从这里继续的新对话',
439
+ sharedBlocked: '这个项目目录还有别的对话正在运行。恢复文件会影响到它们,因此本次操作已被阻止。请等那些对话结束或停止后,再重新检查。',
440
+ branchChanged: '当前所在的 Git 分支和发送这条消息时不同。恢复不会切换分支,只会把当时的文件内容恢复到当前分支。',
441
+ headAdvanced: '这条消息之后有了新的 Git 提交。恢复只会改文件,不会撤销提交;完成后这些文件会显示为未提交修改。',
442
+ driftBlocked: 'Git 正在进行合并、变基或类似操作。请先完成或取消这次 Git 操作,再重新检查。',
443
+ planMissing: '恢复信息已经失效,请重新检查。',
444
+ stale: '项目文件在检查后又发生了变化。为避免覆盖新修改,这次恢复已失效,请重新检查。',
445
+ nothingToRestore: '项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。',
446
+ loadingAllFiles: '正在读取全部文件…',
447
+ viewAllFiles: count => `查看全部 ${String(count)} 个文件`,
448
+ backupNote: '恢复前会自动备份当前文件;若恢复失败会自动还原,项目不会停留在只恢复了一部分的状态。',
449
+ recheck: '重新检查',
450
+ completedMessages: '已创建从这里开始的新对话;项目文件保持不变。',
451
+ completedCode: '项目文件已恢复;当前对话保持不变。恢复前的文件已自动备份。',
452
+ completedBoth: '项目文件已恢复,并已创建新对话。恢复前的文件已自动备份。',
453
+ openFailed: reason => `新对话已创建,但没能自动打开:${reason}`,
454
+ restoredButOpenFailed: reason => `文件已经恢复,新对话也已创建,但没能自动打开:${reason}`,
455
+ modeMismatch: mode => `服务器返回了不匹配的回退模式:${mode}`,
456
+ listChangedWhileExpanding: '项目文件在展开列表时发生了变化。',
457
+ incompleteFileList: '无法读取完整的文件列表。',
458
+ fileKinds: {
459
+ added: '移除后来新增的文件',
460
+ deleted: '找回文件',
461
+ modified: '恢复之前的版本',
462
+ 'mode-changed': '恢复文件权限',
463
+ 'type-changed': '恢复之前的文件类型',
464
+ },
465
+ errors: {
466
+ REWIND_ENDPOINT_UNAVAILABLE: '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。',
467
+ REWIND_INVALID_RESPONSE: '回退服务返回了无法解析的内容。请重启 DSH 后重试。',
468
+ PLAN_STALE: '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。',
469
+ PLAN_STALE_WORKSPACE: '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。',
470
+ PLAN_STALE_REPOSITORY: 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。',
471
+ WORKSPACE_IN_USE: '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。',
472
+ WORKSPACE_LOCKED: '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。',
473
+ WORKSPACE_CHANGED: '这个项目目录已经不在原来的位置,旧的回退点无法再使用。',
474
+ WORKSPACE_MODE_CHANGED: '这个项目目录的工作区类型变了(例如从普通目录变成了 Git 仓库)。旧的回退点不再适用,请重新发送一条消息生成新的回退点。',
475
+ HEAD_CHANGED: '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。',
476
+ REPOSITORY_CHANGED: '这个项目目录已不属于原来的 Git 工作区,无法恢复。',
477
+ GIT_OPERATION_CHANGED: 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。',
478
+ RESTORE_POINT_NOT_FOUND: '没有找到对应的文件状态,可能已被清理。',
479
+ NO_CHANGES: '项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。',
480
+ RESTORE_FAILED_ROLLED_BACK: '恢复未能完成,项目文件已自动还原到操作前的状态。',
481
+ CONVERSATION_REWIND_FAILED: '文件已恢复,但无法创建新对话;项目文件已自动还原。',
482
+ },
483
+ captureSnapshotLimit: '本轮要保存的文件总量超过上限,剩下的文件没有纳入检查点。恢复时不会改动它们。',
484
+ captureFileLimit: '本轮文件数量超过上限,多出来的文件没有纳入检查点。恢复时不会改动它们。',
485
+ captureSkipped: (count, example) => `有 ${String(count)} 个文件因为超过单文件大小上限或类型不受支持,没有纳入检查点${example === undefined ? '' : `(例如 ${example})`}。恢复时不会改动它们。`,
486
+ skipTimeout: '这个项目目录太大,在检查点时间上限内没保存完文件快照,所以本轮没有回退点(消息本身没有受影响)。可以在插件设置里调大「检查点时间上限」,或在目录根用 .dsh-rewindignore 排除大目录(例如 node_modules、构建产物、数据集)。仍可只回溯消息。',
487
+ skipDisabled: '自动文件检查点已在设置里关闭,本轮没有回退点。仍可只回溯消息。',
488
+ skipNewContentLimit: '这一轮新增的内容超过检查点预算,没有保存文件快照。仍可只回溯消息。',
489
+ skipOther: reason => `本轮没有保存文件检查点:${reason}仍可只回溯消息。`,
490
+ failureNotGitRepository: '这个项目目录不是 Git 仓库,回退功能无法保存文件检查点。仍可只回溯消息。',
491
+ failureGitStatus: message => `无法读取这个项目目录的 Git 状态:${message}仍可只回溯消息。`,
492
+ failureSizeOrCount: '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。',
493
+ failureUnsupportedType: '项目目录里有无法保存的特殊文件(如 socket、设备文件、FIFO),没有保存文件检查点。仍可只回溯消息。',
494
+ failureIgnoreFileInvalid: '项目目录里的 .dsh-rewindignore 内容无效,没有保存文件检查点。修正该文件后可重新发送消息。',
495
+ failureInvalidPath: '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。',
496
+ failureOther: message => `没能保存这条消息发送前的文件:${message}仍可只回溯消息。`,
497
+ settingsCardTitle: 'Turn Rewind 回退设置',
498
+ settingsCardDescription: '自动文件检查点、信任策略与检查点保留上限;回退按钮在各条用户消息上',
499
+ autoCheckpointSection: '自动文件检查点',
500
+ settingsLoading: '正在加载设置…',
501
+ settingsUnavailable: '当前部署未提供设置服务,以下选项不可用。',
502
+ settingsReadOnly: '设置为只读(当前浏览器进程内保存),修改不可用。',
503
+ autoCheckpointLabel: '自动文件检查点',
504
+ autoCheckpointDescription: '关闭后不再为每条消息保存文件检查点;回退弹窗仍可只回溯消息',
505
+ trustLabel: '检查点信任策略',
506
+ trustDescription: '快速信任 Git/stat 元数据;严格会逐一重读文件内容',
507
+ overridden: '已覆盖',
508
+ resetToDefault: '恢复默认',
509
+ positiveInteger: field => `${field} 必须是正整数。`,
510
+ storageDir: directory => `存储目录(在 cordis.patch.yml 中配置,不可在线修改):${directory}`,
511
+ manageSection: '检查点管理',
512
+ refreshing: '正在刷新…',
513
+ refresh: '刷新',
514
+ clearing: '正在清理…',
515
+ confirmClearAll: '确认清空全部',
516
+ clearAll: '一键清空全部',
517
+ manageLoading: '正在读取检查点占用…',
518
+ manageTotal: (workspaces, points, size) => `共 ${String(workspaces)} 个工作区,${String(points)} 个检查点,约 ${size}(Git 原生检查点的实际磁盘占用以 Git 回收为准)。`,
519
+ workspacePoints: count => `${String(count)} 个检查点`,
520
+ pendingRecoveries: count => `${String(count)} 个恢复待处理`,
521
+ expand: '展开',
522
+ collapse: '收起',
523
+ clearWorkspace: '清空此项目',
524
+ fileCount: count => `${String(count)} 个文件`,
525
+ deleting: '正在删除…',
526
+ remove: '删除',
527
+ noCheckpoints: '还没有任何已保存的检查点。',
528
+ clearedAll: (deleted, retained, failures) => `已删除 ${String(deleted)} 个检查点${retained > 0 ? `,${String(retained)} 个受保护检查点未删除` : ''}${failures > 0 ? `,${String(failures)} 个工作区清理失败` : ''}。`,
529
+ cleared: (deleted, retained) => `已删除 ${String(deleted)} 个检查点${retained > 0 ? `,${String(retained)} 个受保护检查点未删除` : ''}。`,
530
+ checkpointModes: {
531
+ off: '关闭(不创建文件检查点)',
532
+ auto: '自动(推荐)',
533
+ 'git-native': 'Git 原生(大仓库)',
534
+ legacy: '完整快照(兼容模式)',
535
+ },
536
+ trustOptions: {
537
+ fast: '快速',
538
+ strict: '严格',
539
+ },
540
+ pointKinds: {
541
+ user: '手动',
542
+ rescue: '救援',
543
+ turn: '轮次',
544
+ },
545
+ numberFields: {
546
+ maxRestorePoints: { label: '用户/救援恢复点上限', description: '每个工作区保留的最大手动与救援恢复点数量' },
547
+ maxTurnCheckpointsPerSession: { label: '轮次检查点上限', description: '每个会话保留的最大自动轮次检查点数量(最旧的先清理)' },
548
+ maxFiles: { label: '单恢复点文件数上限', description: '一个恢复点最多纳入的文件数量' },
549
+ maxFileBytes: { label: '单文件大小上限', description: '读取单个普通文件的最大字节数' },
550
+ maxSnapshotBytes: { label: '快照总量上限', description: '单个恢复点读取的最大字节总量' },
551
+ planTtlMs: { label: '恢复计划有效期(毫秒)', description: '回溯计划从创建到失效的时间' },
552
+ staleLockMs: { label: '锁回收时长(毫秒)', description: '锁属主消失多久后允许回收该锁' },
553
+ turnCheckpointTimeoutMs: { label: '检查点超时(毫秒)', description: '单次自动检查点最多阻塞消息发送的时间,超时记录跳过' },
554
+ turnCheckpointMaxNewBytes: { label: '检查点读取上限', description: '单次 Git 原生检查点最多读取的未缓存字节数' },
555
+ },
556
+ manageMissingWorkspaces: '管理数据缺少 workspaces',
557
+ manageMissingRestorePoints: '管理数据缺少 restorePoints',
558
+ clearMissingReports: '清理结果缺少 reports',
559
+ previewMissingChanges: '回退预览缺少 changes',
560
+ previewMissingActiveSessionIds: '回退预览缺少 activeSessionIds',
561
+ invalidField: name => `${name} 无效`,
562
+ invalidObject: '服务器返回了无效对象',
563
+ unknownStatus: status => `未知回退状态:${status}`,
564
+ sessionNotReady: '新对话还没有准备好',
565
+ unparsableResponse: status => `回退服务返回了无法解析的内容(HTTP ${status})。`,
566
+ requestFailed: status => `请求失败:${status}`,
567
+ emptyResponse: '回退服务返回了空响应。',
568
+ }
569
+
570
+ /**
571
+ * Count one English noun, used only by the English copy.
572
+ * @param count - the quantity to render.
573
+ * @param singular - the noun in its singular form.
574
+ * @param plural - the plural form, when adding `s` is wrong.
575
+ * @returns the counted phrase.
576
+ */
577
+ function counted(count: number, singular: string, plural = `${singular}s`): string {
578
+ return `${String(count)} ${count === 1 ? singular : plural}`
579
+ }
580
+
581
+ /** English copy, used when the Host reports a non-Chinese UI language. */
582
+ const EN: RewindText = {
583
+ rewindTooltip: 'Return to before sending this message',
584
+ dialogClose: 'Close',
585
+ dialogDescription: 'Review the files that would be restored and pick the rewind mode you want. The current session is unaffected.',
586
+ cancel: 'Cancel',
587
+ applying: 'Restoring…',
588
+ done: 'Done',
589
+ actionRestoreAndRestart: 'Restore and continue from here',
590
+ actionRestoreFiles: 'Restore files',
591
+ actionMessagesOnly: 'Rewind messages only',
592
+ checkingFiles: 'Checking which project files can be restored…',
593
+ previewPending: 'The files from before this message are still being saved. Try again in a moment.',
594
+ previewMissing: 'No files were saved from before this message. Rewind may not have been enabled yet, the record may have passed its retention limit, or automatic checkpoints may be turned off. You can still rewind messages only.',
595
+ modeBothTitle: 'Restore files and restart',
596
+ modeBothDescription: 'Creates a new session starting here (the current conversation is kept)',
597
+ modeCodeTitle: 'Restore files only',
598
+ modeCodeDescription: 'Restores the files from before this message and leaves the current conversation unchanged.',
599
+ modeMessagesTitle: 'Rewind messages only (files untouched)',
600
+ modeMessagesDescription: 'Creates a new session starting here and leaves the project files exactly as they are.',
601
+ filesUnchanged: 'Project files stay unchanged',
602
+ filesToRestore: count => `Will restore ${counted(count, 'file')}`,
603
+ summaryBoth: 'Continue in a new conversation after restoring',
604
+ summaryCode: 'The current conversation stays unchanged',
605
+ summaryMessages: 'Only creates a new conversation continuing from here',
606
+ sharedBlocked: 'Another conversation is still running in this project directory. Restoring files would affect it, so this operation is blocked. Wait for those conversations to finish or stop them, then check again.',
607
+ branchChanged: 'The current Git branch is not the one you were on when this message was sent. Restoring never switches branches; it only restores that file content onto the current branch.',
608
+ headAdvanced: 'There are new Git commits after this message. Restoring only changes files and never undoes commits; afterwards those files appear as uncommitted changes.',
609
+ driftBlocked: 'Git is in the middle of a merge, rebase, or similar operation. Finish or cancel that Git operation, then check again.',
610
+ planMissing: 'The restore information has expired. Check again.',
611
+ stale: 'The project files changed again after the check. To avoid overwriting the newer edits, this restore is no longer valid. Check again.',
612
+ nothingToRestore: 'The project files already match the state from before this message, so no files need restoring. Choose “Rewind messages only” to restart this conversation from here.',
613
+ loadingAllFiles: 'Loading all files…',
614
+ viewAllFiles: count => `View all ${counted(count, 'file')}`,
615
+ backupNote: 'The current files are backed up automatically before a restore; if the restore fails they are rolled back, so the project is never left partly restored.',
616
+ recheck: 'Check again',
617
+ completedMessages: 'A new conversation starting here has been created; the project files are unchanged.',
618
+ completedCode: 'The project files have been restored; the current conversation is unchanged. The files from before the restore were backed up automatically.',
619
+ completedBoth: 'The project files have been restored and a new conversation was created. The files from before the restore were backed up automatically.',
620
+ openFailed: reason => `The new conversation was created but could not be opened automatically: ${reason}`,
621
+ restoredButOpenFailed: reason => `The files were restored and the new conversation was created, but it could not be opened automatically: ${reason}`,
622
+ modeMismatch: mode => `The server returned a rewind mode that does not match: ${mode}`,
623
+ listChangedWhileExpanding: 'The project files changed while the full list was being loaded.',
624
+ incompleteFileList: 'Could not read the complete file list.',
625
+ fileKinds: {
626
+ added: 'Remove the file added later',
627
+ deleted: 'Bring the file back',
628
+ modified: 'Restore the earlier version',
629
+ 'mode-changed': 'Restore the file permissions',
630
+ 'type-changed': 'Restore the earlier file type',
631
+ },
632
+ errors: {
633
+ REWIND_ENDPOINT_UNAVAILABLE: 'The rewind service did not respond. Check that @anionex/dsh-turn-rewind is mounted in this DSH, restart DSH, and try again.',
634
+ REWIND_INVALID_RESPONSE: 'The rewind service returned content that could not be parsed. Restart DSH and try again.',
635
+ PLAN_STALE: 'The project files changed again after the check. To avoid overwriting the newer edits, check again before restoring.',
636
+ PLAN_STALE_WORKSPACE: 'The project files changed again after the check. To avoid overwriting the newer edits, check again before restoring.',
637
+ PLAN_STALE_REPOSITORY: 'The Git state changed after the check, so the restore is no longer valid. Check again and retry.',
638
+ WORKSPACE_IN_USE: 'Another conversation is still running in this project directory. Wait for those conversations to finish or stop them, then check again.',
639
+ WORKSPACE_LOCKED: 'Another restore is already working on this project directory. Wait for it to finish, then check again.',
640
+ WORKSPACE_CHANGED: 'This project directory is no longer in its original location, so the earlier restore points cannot be used.',
641
+ WORKSPACE_MODE_CHANGED: 'The workspace type of this project directory changed (for example from an ordinary directory to a Git repository). The earlier restore points no longer apply; send a new message to create a new one.',
642
+ HEAD_CHANGED: 'The commits or branch of this project changed. To avoid overwriting newer work, check again before restoring.',
643
+ REPOSITORY_CHANGED: 'This project directory no longer belongs to its original Git worktree, so it cannot be restored.',
644
+ GIT_OPERATION_CHANGED: 'Git is running another operation. Finish or cancel it, then check again.',
645
+ RESTORE_POINT_NOT_FOUND: 'No matching file state was found; it may already have been cleaned up.',
646
+ NO_CHANGES: 'The project files already match the state from before this message, so no files need restoring. Choose “Rewind messages only” to restart this conversation from here.',
647
+ RESTORE_FAILED_ROLLED_BACK: 'The restore did not finish; the project files were rolled back to their state before the operation.',
648
+ CONVERSATION_REWIND_FAILED: 'The files were restored but the new conversation could not be created; the project files were rolled back.',
649
+ },
650
+ captureSnapshotLimit: 'The files in this turn exceeded the total size limit, so the remaining files were left out of the checkpoint. A restore will not touch them.',
651
+ captureFileLimit: 'This turn had more files than the limit allows, so the extra files were left out of the checkpoint. A restore will not touch them.',
652
+ captureSkipped: (count, example) => `${counted(count, 'file')} ${count === 1 ? 'was' : 'were'} left out of the checkpoint for exceeding the per-file size limit or having an unsupported type${example === undefined ? '' : ` (for example ${example})`}. A restore will not touch them.`,
653
+ skipTimeout: 'This project directory is too large to finish a file snapshot within the checkpoint time limit, so this turn has no restore point (the message itself was unaffected). Raise “Checkpoint timeout (ms)” in the plugin settings, or exclude large directories with a .dsh-rewindignore in the directory root (for example node_modules, build output, datasets). You can still rewind messages only.',
654
+ skipDisabled: 'Automatic file checkpoints are turned off in settings, so this turn has no restore point. You can still rewind messages only.',
655
+ skipNewContentLimit: 'This turn added more content than the checkpoint budget allows, so no file snapshot was saved. You can still rewind messages only.',
656
+ skipOther: reason => `No file checkpoint was saved for this turn: ${reason} You can still rewind messages only.`,
657
+ failureNotGitRepository: 'This project directory is not a Git repository, so rewind cannot save file checkpoints. You can still rewind messages only.',
658
+ failureGitStatus: message => `Could not read the Git state of this project directory: ${message} You can still rewind messages only.`,
659
+ failureSizeOrCount: 'Files in this turn exceeded the checkpoint size or count limit, so no file checkpoint was saved. You can still rewind messages only.',
660
+ failureUnsupportedType: 'The project directory contains special files that cannot be saved (such as sockets, device files, or FIFOs), so no file checkpoint was saved. You can still rewind messages only.',
661
+ failureIgnoreFileInvalid: 'The .dsh-rewindignore in the project directory is invalid, so no file checkpoint was saved. Fix that file and send the message again.',
662
+ failureInvalidPath: 'The project directory contains paths that cannot be saved safely (for example a nested standalone Git repository), so no file checkpoint was saved. You can still rewind messages only.',
663
+ failureOther: message => `Could not save the files from before this message: ${message} You can still rewind messages only.`,
664
+ settingsCardTitle: 'Turn Rewind settings',
665
+ settingsCardDescription: 'Automatic file checkpoints, trust policy, and checkpoint retention limits; the rewind button sits on each user message',
666
+ autoCheckpointSection: 'Automatic file checkpoints',
667
+ settingsLoading: 'Loading settings…',
668
+ settingsUnavailable: 'This deployment provides no settings service, so the options below are unavailable.',
669
+ settingsReadOnly: 'Settings are read-only (kept inside this browser process), so changes are unavailable.',
670
+ autoCheckpointLabel: 'Automatic file checkpoints',
671
+ autoCheckpointDescription: 'When off, no file checkpoint is saved for each message; the rewind dialog can still rewind messages only',
672
+ trustLabel: 'Checkpoint trust policy',
673
+ trustDescription: 'Fast trusts Git/stat metadata; Strict re-reads every file’s content',
674
+ overridden: 'Overridden',
675
+ resetToDefault: 'Reset to default',
676
+ positiveInteger: field => `${field} must be a positive integer.`,
677
+ storageDir: directory => `Storage directory (configured in cordis.patch.yml, not editable here): ${directory}`,
678
+ manageSection: 'Checkpoint management',
679
+ refreshing: 'Refreshing…',
680
+ refresh: 'Refresh',
681
+ clearing: 'Clearing…',
682
+ confirmClearAll: 'Confirm clear all',
683
+ clearAll: 'Clear all',
684
+ manageLoading: 'Reading checkpoint usage…',
685
+ manageTotal: (workspaces, points, size) => `${counted(workspaces, 'workspace')}, ${counted(points, 'checkpoint')}, about ${size} (disk use for Git-native checkpoints follows Git garbage collection).`,
686
+ workspacePoints: count => counted(count, 'checkpoint'),
687
+ pendingRecoveries: count => `${counted(count, 'recovery', 'recoveries')} pending`,
688
+ expand: 'Expand',
689
+ collapse: 'Collapse',
690
+ clearWorkspace: 'Clear this project',
691
+ fileCount: count => counted(count, 'file'),
692
+ deleting: 'Deleting…',
693
+ remove: 'Delete',
694
+ noCheckpoints: 'No checkpoints have been saved yet.',
695
+ clearedAll: (deleted, retained, failures) => `Deleted ${counted(deleted, 'checkpoint')}${retained > 0 ? `; ${counted(retained, 'protected checkpoint')} kept` : ''}${failures > 0 ? `; ${counted(failures, 'workspace')} failed to clear` : ''}.`,
696
+ cleared: (deleted, retained) => `Deleted ${counted(deleted, 'checkpoint')}${retained > 0 ? `; ${counted(retained, 'protected checkpoint')} kept` : ''}.`,
697
+ checkpointModes: {
698
+ off: 'Off (no file checkpoints)',
699
+ auto: 'Auto (recommended)',
700
+ 'git-native': 'Git-native (large repositories)',
701
+ legacy: 'Full snapshot (compatibility mode)',
702
+ },
703
+ trustOptions: {
704
+ fast: 'Fast',
705
+ strict: 'Strict',
706
+ },
707
+ pointKinds: {
708
+ user: 'Manual',
709
+ rescue: 'Rescue',
710
+ turn: 'Turn',
711
+ },
712
+ numberFields: {
713
+ maxRestorePoints: { label: 'User/rescue restore point limit', description: 'Most manual and rescue restore points kept per workspace' },
714
+ maxTurnCheckpointsPerSession: { label: 'Turn checkpoint limit', description: 'Most automatic turn checkpoints kept per session (oldest pruned first)' },
715
+ maxFiles: { label: 'Files per restore point', description: 'Most files one restore point may include' },
716
+ maxFileBytes: { label: 'Maximum file size', description: 'Most bytes read from a single regular file' },
717
+ maxSnapshotBytes: { label: 'Maximum snapshot size', description: 'Most bytes read in total for one restore point' },
718
+ planTtlMs: { label: 'Restore plan lifetime (ms)', description: 'How long a rewind plan stays valid after it is created' },
719
+ staleLockMs: { label: 'Stale lock timeout (ms)', description: 'How long after its owner disappears a lock may be reclaimed' },
720
+ turnCheckpointTimeoutMs: { label: 'Checkpoint timeout (ms)', description: 'How long one automatic checkpoint may hold up sending a message before it is recorded as skipped' },
721
+ turnCheckpointMaxNewBytes: { label: 'Checkpoint read limit', description: 'Most uncached bytes one Git-native checkpoint may read' },
722
+ },
723
+ manageMissingWorkspaces: 'The management data is missing workspaces',
724
+ manageMissingRestorePoints: 'The management data is missing restorePoints',
725
+ clearMissingReports: 'The clear result is missing reports',
726
+ previewMissingChanges: 'The rewind preview is missing changes',
727
+ previewMissingActiveSessionIds: 'The rewind preview is missing activeSessionIds',
728
+ invalidField: name => `${name} is invalid`,
729
+ invalidObject: 'The server returned an invalid object',
730
+ unknownStatus: status => `Unknown rewind status: ${status}`,
731
+ sessionNotReady: 'The new conversation is not ready yet',
732
+ unparsableResponse: status => `The rewind service returned content that could not be parsed (HTTP ${status}).`,
733
+ requestFailed: status => `Request failed: ${status}`,
734
+ emptyResponse: 'The rewind service returned an empty response.',
735
+ }
736
+
737
+ const TEXT: Readonly<Record<UiLocale, RewindText>> = { zh: ZH, en: EN }
738
+
739
+ /**
740
+ * Active UI language, and the listeners rendering it.
741
+ *
742
+ * One browser runs one copy of this plugin, so the active language is module
743
+ * state rather than React context: the pure helpers below (`friendlyError`,
744
+ * `explainCheckpointSkip`, and the decoders) produce user-facing sentences
745
+ * outside any render and would otherwise need their exported signatures
746
+ * changed.
747
+ */
748
+ let uiLocale: UiLocale = 'zh'
749
+ const localeListeners = new Set<() => void>()
750
+
751
+ /**
752
+ * Resolve one Host locale id to a language this plugin ships copy for.
753
+ *
754
+ * An absent, empty, or unreadable id means the Host published no UI language —
755
+ * DSH releases without the locale plugin, and profiles that do not mount it —
756
+ * and keeps Chinese, so an existing install never changes language on upgrade.
757
+ * Any other registered language resolves to English, mirroring the Host's own
758
+ * per-key fallback chain, which terminates at English rather than Chinese.
759
+ * @param active - the Host's active locale id, when it publishes one.
760
+ * @returns the language whose copy to render.
761
+ */
762
+ export function resolveUiLocale(active: string | undefined): UiLocale {
763
+ if (typeof active !== 'string' || active === '') return 'zh'
764
+ return /^zh(?:[-_]|$)/i.test(active) ? 'zh' : 'en'
765
+ }
766
+
767
+ /** Current UI copy. Chinese until the Host publishes another language. */
768
+ function uiText(): RewindText {
769
+ return TEXT[uiLocale]
770
+ }
771
+
772
+ /** Adopt one Host locale id and re-render everything currently mounted. */
773
+ function adoptLocale(active: string | undefined): void {
774
+ const next = resolveUiLocale(active)
775
+ if (next === uiLocale) return
776
+ uiLocale = next
777
+ for (const listener of localeListeners) listener()
778
+ }
779
+
780
+ /** `useSyncExternalStore` subscribe half for the active language. */
781
+ function subscribeLocale(listener: () => void): () => void {
782
+ localeListeners.add(listener)
783
+ return () => { localeListeners.delete(listener) }
784
+ }
785
+
786
+ /** Read the current copy and re-render this component when the language changes. */
787
+ function useText(): RewindText {
788
+ return useSyncExternalStore(subscribeLocale, uiText, uiText)
789
+ }
790
+
791
+ /**
792
+ * Follow the Host's UI language for as long as the plugin is mounted.
793
+ *
794
+ * The locale service is read through `ctx.get`, Cordis's un-injected read, and
795
+ * not declared in `inject`: an injected service the profile does not mount
796
+ * holds the fiber inactive, which would stop the rewind button appearing at all
797
+ * on a profile without the locale plugin. Late arrival is covered by the
798
+ * `locale/change` event, which is emitted on the context rather than on the
799
+ * service, so a listener registered here sees a switch even when the locale
800
+ * plugin loads after this one.
801
+ * @param ctx - the client context this plugin was applied to.
802
+ * @returns the teardown restoring the default language.
803
+ */
804
+ function followHostLocale(ctx: ClientContextLike): () => void {
805
+ adoptLocale(hostLocale(ctx))
806
+ // Client plugin apply() order is not guaranteed, and `locale` is read
807
+ // un-injected (see the doc comment above) so this plugin never forces it
808
+ // to load first. When `dsh-client-locale` mounts after this plugin, the
809
+ // read above returns `undefined` and the surface falls back to Chinese;
810
+ // nothing then corrects it, because a stored preference that was already
811
+ // settled before this plugin mounted never fires another `locale/change`
812
+ // event. Re-checking once on the next microtask and once on the next
813
+ // macrotask catches both same-tick and deferred registration without
814
+ // adding a hard dependency; `adoptLocale` is a no-op when the language
815
+ // has not actually changed, so the extra calls are always safe.
816
+ const recheck = (): void => adoptLocale(hostLocale(ctx))
817
+ // `Promise` is an ECMAScript built-in present in every realm, unlike the
818
+ // host-provided `queueMicrotask`, which a constrained embedding (this
819
+ // plugin's own test sandbox included) need not supply.
820
+ void Promise.resolve().then(recheck)
821
+ const timeoutId = setTimeout(recheck, 0)
822
+ let off: unknown
823
+ try {
824
+ off = ctx.on?.('locale/change', (snapshot: LocaleSnapshotLike) => {
825
+ adoptLocale(typeof snapshot?.active === 'string' ? snapshot.active : hostLocale(ctx))
826
+ })
827
+ } catch {
828
+ off = undefined
829
+ }
830
+ return () => {
831
+ clearTimeout(timeoutId)
832
+ if (typeof off === 'function') off()
833
+ adoptLocale(undefined)
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Read the Host's active locale id.
839
+ *
840
+ * The locale service belongs to the Host, so its presence and shape are not
841
+ * this plugin's to guarantee: a language that cannot be read leaves the rewind
842
+ * surface in Chinese instead of failing the effect that installs the button.
843
+ * @param ctx - the client context this plugin was applied to.
844
+ * @returns the active locale id, or `undefined` when none can be read.
845
+ */
846
+ function hostLocale(ctx: ClientContextLike): string | undefined {
847
+ try {
848
+ const active = ctx.get?.('locale')?.getSnapshot().active
849
+ return typeof active === 'string' ? active : undefined
850
+ } catch {
851
+ return undefined
852
+ }
853
+ }
854
+
274
855
  /** Return the rewind anchor and editable text owned by one direct user message. */
275
856
  export function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null {
276
857
  if (node.kind !== 'user' || !Number.isSafeInteger(node.seq) || node.seq < 0) return null
@@ -286,11 +867,12 @@ export function selectRewindMessage(node: ConversationNodeLike): RewindMatch | n
286
867
  *
287
868
  * Every service read on `ctx` must be declared here: Cordis throws while reading an
288
869
  * undeclared service off the context proxy, before optional chaining can apply.
289
- * `settingsScope` is provided by `@deepseek-ai/dsh-client-ui-settings` and may be
290
- * absent, which is what `ctx.settingsScope?.bind(…)` below relies on.
870
+ * The settings transport is read through `ctx.get`: older Hosts expose
871
+ * `settingsScope`, while DSH 0.1.7 exposes `configForms`.
291
872
  */
292
- export const inject = ['slots', 'sessions', 'conversation', 'settingsScope']
873
+ export const inject = ['slots', 'sessions', 'conversation']
293
874
  export function apply(ctx: ClientContextLike): void {
875
+ ctx.effect(() => followHostLocale(ctx), 'turn-rewind: locale')
294
876
  ctx.effect(() => {
295
877
  if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {}
296
878
  const tag = document.createElement('style')
@@ -314,9 +896,25 @@ export function apply(ctx: ClientContextLike): void {
314
896
  name: 'settings.plugin.item',
315
897
  key: 'turn-rewind',
316
898
  inject: () => ({
317
- scope: ctx.settingsScope?.bind<TurnRewindSettingsValue>({ namespace: 'turn-rewind' }),
899
+ scope: settingsFormScope(ctx),
318
900
  }),
319
901
  }, TurnRewindSettingsCard))
902
+ // DSH 0.1.7 replaced the per-plugin item seat with feature-owned Settings tabs.
903
+ ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
904
+ name: 'settings.plugins.tab',
905
+ id: 'turn-rewind',
906
+ order: 80,
907
+ label: () => uiText().settingsCardTitle,
908
+ inject: () => ({ scope: settingsFormScope(ctx) }),
909
+ }, TurnRewindSettingsCard))
910
+ }
911
+
912
+ /** Read whichever settings transport this Host supplies without requiring both services. */
913
+ function settingsFormScope(ctx: ClientContextLike): SettingsScopeLike<TurnRewindSettingsValue> | undefined {
914
+ const forms = ctx.get?.('configForms') as { get<T>(entryId: string): SettingsScopeLike<T> } | undefined
915
+ if (forms !== undefined) return forms.get<TurnRewindSettingsValue>('turn-rewind')
916
+ const legacy = ctx.get?.('settingsScope') as { bind<T>(spec: { readonly namespace: string }): SettingsScopeLike<T> } | undefined
917
+ return legacy?.bind<TurnRewindSettingsValue>({ namespace: 'turn-rewind' })
320
918
  }
321
919
 
322
920
  /** Narrow a chat node source to the keyed store form. */
@@ -399,6 +997,7 @@ export function RewindMessagePortals({ sessionId, openRestoredSession, useSessio
399
997
 
400
998
  /** User-message action and its review-first file/conversation restore dialog. */
401
999
  export function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode {
1000
+ const t = useText()
402
1001
  const [open, setOpen] = useState(false)
403
1002
  const [loading, setLoading] = useState(false)
404
1003
  const [preview, setPreview] = useState<Preview | null>(null)
@@ -502,13 +1101,13 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
502
1101
  || page.checkpointId !== ready.checkpointId
503
1102
  || page.totalChanges !== ready.totalChanges
504
1103
  || page.offset !== offset) {
505
- throw new RewindRequestError('PLAN_STALE', '项目文件在展开列表时发生了变化。')
1104
+ throw new RewindRequestError('PLAN_STALE', t.listChangedWhileExpanding)
506
1105
  }
507
1106
  collected.push(...page.changes)
508
1107
  offset += page.changes.length
509
1108
  if (page.changes.length === 0) break
510
1109
  }
511
- if (offset !== ready.totalChanges) throw new RewindRequestError('PLAN_STALE', '无法读取完整的文件列表。')
1110
+ if (offset !== ready.totalChanges) throw new RewindRequestError('PLAN_STALE', t.incompleteFileList)
512
1111
  setPreview({ ...ready, changes: collected, truncated: false })
513
1112
  } catch (caught) {
514
1113
  if (caught instanceof RewindRequestError && caught.code === 'PLAN_STALE') setStale(true)
@@ -542,31 +1141,31 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
542
1141
  })
543
1142
  const result = recordOf(await responseJson(response))
544
1143
  const resultMode = requiredString(result.mode, 'mode')
545
- if (resultMode !== mode) throw new Error(`服务器返回了不匹配的回退模式:${resultMode}`)
1144
+ if (resultMode !== mode) throw new Error(t.modeMismatch(resultMode))
546
1145
  if (mode === 'messages') {
547
1146
  const childSessionId = requiredString(result.sessionId, 'sessionId')
548
- setCompleted('已创建从这里开始的新对话;项目文件保持不变。')
1147
+ setCompleted(t.completedMessages)
549
1148
  try {
550
1149
  await openRestoredSession(childSessionId, matched.promptText)
551
1150
  setOpen(false)
552
1151
  } catch (navigationError) {
553
- setError(`新对话已创建,但没能自动打开:${messageOf(navigationError)}`)
1152
+ setError(t.openFailed(messageOf(navigationError)))
554
1153
  }
555
1154
  return
556
1155
  }
557
1156
  if (mode === 'code') {
558
1157
  requiredString(result.rescuePointId, 'rescuePointId')
559
- setCompleted('项目文件已恢复;当前对话保持不变。恢复前的文件已自动备份。')
1158
+ setCompleted(t.completedCode)
560
1159
  return
561
1160
  }
562
1161
  const childSessionId = requiredString(result.sessionId, 'sessionId')
563
1162
  requiredString(result.rescuePointId, 'rescuePointId')
564
- setCompleted('项目文件已恢复,并已创建新对话。恢复前的文件已自动备份。')
1163
+ setCompleted(t.completedBoth)
565
1164
  try {
566
1165
  await openRestoredSession(childSessionId, matched.promptText)
567
1166
  setOpen(false)
568
1167
  } catch (navigationError) {
569
- setError(`文件已经恢复,新对话也已创建,但没能自动打开:${messageOf(navigationError)}`)
1168
+ setError(t.restoredButOpenFailed(messageOf(navigationError)))
570
1169
  }
571
1170
  } catch (caught) {
572
1171
  if (caught instanceof RewindRequestError && (caught.code === 'PLAN_STALE' || caught.code === 'WORKSPACE_IN_USE')) {
@@ -580,53 +1179,53 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
580
1179
  }
581
1180
 
582
1181
  const captureNotice = describeCaptureNotice(ready)
583
- const actionLabel = mode === 'both' ? '恢复并从这里继续' : mode === 'code' ? '恢复文件' : '只回溯消息'
1182
+ const actionLabel = mode === 'both' ? t.actionRestoreAndRestart : mode === 'code' ? t.actionRestoreFiles : t.actionMessagesOnly
584
1183
  const radioName = `dcl-rewind-${sessionId}-${String(matched.messageSeq)}`
585
1184
  const branchChanged = ready !== null && ready.checkpointBranch !== ready.currentBranch
586
1185
 
587
1186
  return (
588
1187
  <div className="dcl-rewind-tail">
589
- <Tooltip label="恢复到发送这条消息之前" side="bottom">
590
- <button type="button" className="dcl-rewind-trigger" onClick={show} aria-label="恢复到发送这条消息之前">
1188
+ <Tooltip label={t.rewindTooltip} side="bottom">
1189
+ <button type="button" className="dcl-rewind-trigger" onClick={show} aria-label={t.rewindTooltip}>
591
1190
  <RewindIcon size={16} />
592
1191
  </button>
593
1192
  </Tooltip>
594
1193
  <Modal
595
1194
  open={open}
596
1195
  onClose={close}
597
- title="恢复到发送这条消息之前"
598
- closeLabel="关闭"
599
- description="查看恢复的文件,选择适合你的回退方式。当前会话不受影响。"
1196
+ title={t.rewindTooltip}
1197
+ closeLabel={t.dialogClose}
1198
+ description={t.dialogDescription}
600
1199
  className="dcl-rewind-dialog"
601
1200
  contentClassName="dcl-rewind-content"
602
1201
  footer={(
603
1202
  <>
604
- <Button variant="outline" onClick={close} disabled={applying}>取消</Button>
1203
+ <Button variant="outline" onClick={close} disabled={applying}>{t.cancel}</Button>
605
1204
  <Button variant="primary" onClick={() => { void applyRestore() }} disabled={!canApply}>
606
- {applying ? '正在恢复…' : completed === null ? actionLabel : '已完成'}
1205
+ {applying ? t.applying : completed === null ? actionLabel : t.done}
607
1206
  </Button>
608
1207
  </>
609
1208
  )}
610
1209
  >
611
1210
  <div className="dcl-rewind-body">
612
- {loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
613
- {preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
614
- {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能、记录已超过保留期限,或已关闭自动检查点。仍可只回溯消息。</p>}
1211
+ {loading && <p className="dcl-rewind-status">{t.checkingFiles}</p>}
1212
+ {preview?.status === 'pending' && <p className="dcl-rewind-status">{t.previewPending}</p>}
1213
+ {preview?.status === 'missing' && <p className="dcl-rewind-error">{t.previewMissing}</p>}
615
1214
  {preview?.status === 'skipped' && <p className="dcl-rewind-status">{explainCheckpointSkip(preview.reason)}</p>}
616
1215
  {preview?.status === 'failed' && <p className="dcl-rewind-error">{explainCheckpointFailure(preview.error)}</p>}
617
1216
  {preview !== null && (
618
1217
  <div className="dcl-rewind-options">
619
1218
  <label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying || !hasFileChanges}>
620
1219
  <input type="radio" name={radioName} checked={mode === 'both'} disabled={applying || !hasFileChanges} onChange={() => { chooseMode('both') }} />
621
- <span className="dcl-rewind-option-content"><strong>恢复文件并从这里继续</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话(当前对话会保留)</span></span>
1220
+ <span className="dcl-rewind-option-content"><strong>{t.modeBothTitle}</strong><span className="dcl-rewind-option-description">{t.modeBothDescription}</span></span>
622
1221
  </label>
623
1222
  <label className="dcl-rewind-option" data-selected={mode === 'code'} data-disabled={applying || !hasFileChanges}>
624
1223
  <input type="radio" name={radioName} checked={mode === 'code'} disabled={applying || !hasFileChanges} onChange={() => { chooseMode('code') }} />
625
- <span className="dcl-rewind-option-content"><strong>只恢复文件</strong><span className="dcl-rewind-option-description">恢复这条消息发送前的文件,当前对话保持不变。</span></span>
1224
+ <span className="dcl-rewind-option-content"><strong>{t.modeCodeTitle}</strong><span className="dcl-rewind-option-description">{t.modeCodeDescription}</span></span>
626
1225
  </label>
627
1226
  <label className="dcl-rewind-option" data-selected={mode === 'messages'} data-disabled={applying}>
628
1227
  <input type="radio" name={radioName} checked={mode === 'messages'} disabled={applying} onChange={() => { chooseMode('messages') }} />
629
- <span className="dcl-rewind-option-content"><strong>只回溯消息(不动文件)</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话,项目文件保持当前状态。</span></span>
1228
+ <span className="dcl-rewind-option-content"><strong>{t.modeMessagesTitle}</strong><span className="dcl-rewind-option-description">{t.modeMessagesDescription}</span></span>
630
1229
  </label>
631
1230
  </div>
632
1231
  )}
@@ -635,36 +1234,34 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
635
1234
  {captureNotice !== null && <p className="dcl-rewind-warning">{captureNotice}</p>}
636
1235
  <div className="dcl-rewind-summary">
637
1236
  {mode === 'messages'
638
- ? <strong>项目文件保持不变</strong>
639
- : <strong>将恢复 {String(ready.totalChanges)} 个文件</strong>}
640
- <span>{mode === 'both' ? '恢复后在新对话里继续' : mode === 'code' ? '当前对话保持不变' : '仅创建从这里继续的新对话'}</span>
1237
+ ? <strong>{t.filesUnchanged}</strong>
1238
+ : <strong>{t.filesToRestore(ready.totalChanges)}</strong>}
1239
+ <span>{mode === 'both' ? t.summaryBoth : mode === 'code' ? t.summaryCode : t.summaryMessages}</span>
641
1240
  </div>
642
1241
  {sharedBlocked && (
643
- <p className="dcl-rewind-error">这个项目目录还有别的对话正在运行。恢复文件会影响到它们,因此本次操作已被阻止。请等那些对话结束或停止后,再重新检查。</p>
1242
+ <p className="dcl-rewind-error">{t.sharedBlocked}</p>
644
1243
  )}
645
1244
  {ready.headChanged && !ready.operationChanged && (
646
- <p className="dcl-rewind-warning">{branchChanged
647
- ? '当前所在的 Git 分支和发送这条消息时不同。恢复不会切换分支,只会把当时的文件内容恢复到当前分支。'
648
- : '这条消息之后有了新的 Git 提交。恢复只会改文件,不会撤销提交;完成后这些文件会显示为未提交修改。'}</p>
1245
+ <p className="dcl-rewind-warning">{branchChanged ? t.branchChanged : t.headAdvanced}</p>
649
1246
  )}
650
- {driftBlocked && <p className="dcl-rewind-warning">Git 正在进行合并、变基或类似操作。请先完成或取消这次 Git 操作,再重新检查。</p>}
651
- {planMissing && <p className="dcl-rewind-error">恢复信息已经失效,请重新检查。</p>}
652
- {stale && <p className="dcl-rewind-error">项目文件在检查后又发生了变化。为避免覆盖新修改,这次恢复已失效,请重新检查。</p>}
653
- {ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。</p>}
1247
+ {driftBlocked && <p className="dcl-rewind-warning">{t.driftBlocked}</p>}
1248
+ {planMissing && <p className="dcl-rewind-error">{t.planMissing}</p>}
1249
+ {stale && <p className="dcl-rewind-error">{t.stale}</p>}
1250
+ {ready.totalChanges === 0 && <p className="dcl-rewind-status">{t.nothingToRestore}</p>}
654
1251
  {ready.changes.length > 0 && (
655
1252
  <div className="dcl-rewind-files">
656
1253
  {ready.changes.map(change => <div className="dcl-rewind-file" key={change.path}><code>{change.path}</code><span className="dcl-rewind-kind">{fileRecoveryLabel(change.kind)}</span></div>)}
657
1254
  </div>
658
1255
  )}
659
1256
  {ready.truncated && (
660
- <div className="dcl-rewind-file-actions"><Button variant="outline" size="sm" onClick={() => { void loadAllChanges() }} disabled={loadingDetails}>{loadingDetails ? '正在读取全部文件…' : `查看全部 ${String(ready.totalChanges)} 个文件`}</Button></div>
1257
+ <div className="dcl-rewind-file-actions"><Button variant="outline" size="sm" onClick={() => { void loadAllChanges() }} disabled={loadingDetails}>{loadingDetails ? t.loadingAllFiles : t.viewAllFiles(ready.totalChanges)}</Button></div>
661
1258
  )}
662
1259
  </>
663
1260
  )}
664
1261
  {completed !== null && <p className="dcl-rewind-status">{completed}</p>}
665
1262
  {error !== null && <p className="dcl-rewind-error">{error}</p>}
666
- {error !== null && <p className="dcl-rewind-backup">恢复前会自动备份当前文件;若恢复失败会自动还原,项目不会停留在只恢复了一部分的状态。</p>}
667
- {!loading && (preview?.status !== 'ready' || stale || planMissing || sharedBlocked || driftBlocked) && <Button className="dcl-rewind-retry" variant="outline" size="sm" onClick={() => { void load() }}>重新检查</Button>}
1263
+ {error !== null && <p className="dcl-rewind-backup">{t.backupNote}</p>}
1264
+ {!loading && (preview?.status !== 'ready' || stale || planMissing || sharedBlocked || driftBlocked) && <Button className="dcl-rewind-retry" variant="outline" size="sm" onClick={() => { void load() }}>{t.recheck}</Button>}
668
1265
  </div>
669
1266
  </Modal>
670
1267
  </div>
@@ -688,38 +1285,21 @@ type NumberSettingsField = keyof Pick<TurnRewindSettingsValue,
688
1285
  'maxRestorePoints' | 'maxTurnCheckpointsPerSession' | 'maxFiles' | 'maxFileBytes' | 'maxSnapshotBytes'
689
1286
  | 'planTtlMs' | 'staleLockMs' | 'turnCheckpointTimeoutMs' | 'turnCheckpointMaxNewBytes'>
690
1287
 
691
- const NUMBER_FIELDS: readonly { readonly key: NumberSettingsField; readonly label: string; readonly description: string }[] = [
692
- { key: 'maxRestorePoints', label: '用户/救援恢复点上限', description: '每个工作区保留的最大手动与救援恢复点数量' },
693
- { key: 'maxTurnCheckpointsPerSession', label: '轮次检查点上限', description: '每个会话保留的最大自动轮次检查点数量(最旧的先清理)' },
694
- { key: 'maxFiles', label: '单恢复点文件数上限', description: '一个恢复点最多纳入的文件数量' },
695
- { key: 'maxFileBytes', label: '单文件大小上限', description: '读取单个普通文件的最大字节数' },
696
- { key: 'maxSnapshotBytes', label: '快照总量上限', description: '单个恢复点读取的最大字节总量' },
697
- { key: 'planTtlMs', label: '恢复计划有效期(毫秒)', description: '回溯计划从创建到失效的时间' },
698
- { key: 'staleLockMs', label: '锁回收时长(毫秒)', description: '锁属主消失多久后允许回收该锁' },
699
- { key: 'turnCheckpointTimeoutMs', label: '检查点超时(毫秒)', description: '单次自动检查点最多阻塞消息发送的时间,超时记录跳过' },
700
- { key: 'turnCheckpointMaxNewBytes', label: '检查点读取上限', description: '单次 Git 原生检查点最多读取的未缓存字节数' },
1288
+ /** Numeric settings fields, in the order the card renders them. */
1289
+ const NUMBER_FIELDS: readonly NumberSettingsField[] = [
1290
+ 'maxRestorePoints', 'maxTurnCheckpointsPerSession', 'maxFiles', 'maxFileBytes', 'maxSnapshotBytes',
1291
+ 'planTtlMs', 'staleLockMs', 'turnCheckpointTimeoutMs', 'turnCheckpointMaxNewBytes',
701
1292
  ]
702
1293
 
703
- const CHECKPOINT_MODE_LABELS: Readonly<Record<TurnRewindSettingsValue['turnCheckpointMode'], string>> = {
704
- off: '关闭(不创建文件检查点)',
705
- auto: '自动(推荐)',
706
- 'git-native': 'Git 原生(大仓库)',
707
- legacy: '完整快照(兼容模式)',
708
- }
1294
+ /** Checkpoint modes, in the order the select offers them. */
1295
+ const CHECKPOINT_MODES: readonly TurnRewindSettingsValue['turnCheckpointMode'][] = ['off', 'auto', 'git-native', 'legacy']
709
1296
 
710
- const TRUST_LABELS: Readonly<Record<TurnRewindSettingsValue['turnCheckpointTrust'], string>> = {
711
- fast: '快速',
712
- strict: '严格',
713
- }
714
-
715
- const POINT_KIND_LABELS: Readonly<Record<string, string>> = {
716
- user: '手动',
717
- rescue: '救援',
718
- turn: '轮次',
719
- }
1297
+ /** Checkpoint trust policies, in the order the select offers them. */
1298
+ const TRUST_MODES: readonly TurnRewindSettingsValue['turnCheckpointTrust'][] = ['fast', 'strict']
720
1299
 
721
1300
  /** Settings card for the `turn-rewind` namespace: runtime options plus checkpoint management. */
722
1301
  export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps): ReactNode {
1302
+ const t = useText()
723
1303
  const snapshot = useSyncExternalStore(
724
1304
  useCallback((notify: () => void) => scope?.subscribe(notify) ?? (() => {}), [scope]),
725
1305
  useCallback(() => scope?.getSnapshot() ?? EMPTY_SETTINGS_SNAPSHOT, [scope]),
@@ -773,7 +1353,7 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
773
1353
  }
774
1354
  const parsed = Number.parseInt(draft, 10)
775
1355
  if (!Number.isSafeInteger(parsed) || parsed <= 0) {
776
- setFormError(`${field} 必须是正整数。`)
1356
+ setFormError(t.positiveInteger(field))
777
1357
  return
778
1358
  }
779
1359
  if (parsed === value?.[field]) return
@@ -820,8 +1400,8 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
820
1400
  onClick={() => { setCardOpen(value => !value) }}
821
1401
  >
822
1402
  <span className="dcl-trs-card-heading">
823
- <strong>Turn Rewind 回退设置</strong>
824
- <span className="dcl-trs-card-description">自动文件检查点、信任策略与检查点保留上限;回退按钮在各条用户消息上</span>
1403
+ <strong>{t.settingsCardTitle}</strong>
1404
+ <span className="dcl-trs-card-description">{t.settingsCardDescription}</span>
825
1405
  </span>
826
1406
  <svg className="dcl-trs-card-chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
827
1407
  <path d="M4.5 6.5 8 10l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -831,70 +1411,70 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
831
1411
  <div className="dcl-trs-card-body">
832
1412
  <section className="dcl-trs-section">
833
1413
  <div className="dcl-trs-section-title">
834
- <strong>自动文件检查点</strong>
1414
+ <strong>{t.autoCheckpointSection}</strong>
835
1415
  </div>
836
- {snapshot.status === 'loading' && <p className="dcl-trs-status">正在加载设置…</p>}
837
- {snapshot.status === 'unavailable' && <p className="dcl-trs-status">当前部署未提供设置服务,以下选项不可用。</p>}
838
- {snapshot.status === 'ready' && !snapshot.writable && <p className="dcl-trs-status">设置为只读(当前浏览器进程内保存),修改不可用。</p>}
1416
+ {snapshot.status === 'loading' && <p className="dcl-trs-status">{t.settingsLoading}</p>}
1417
+ {snapshot.status === 'unavailable' && <p className="dcl-trs-status">{t.settingsUnavailable}</p>}
1418
+ {snapshot.status === 'ready' && !snapshot.writable && <p className="dcl-trs-status">{t.settingsReadOnly}</p>}
839
1419
  {value !== undefined && (
840
1420
  <>
841
1421
  <div className="dcl-trs-field">
842
- <span className="dcl-trs-field-label"><strong>自动文件检查点</strong>
843
- <span className="dcl-trs-field-desc">关闭后不再为每条消息保存文件检查点;回退弹窗仍可只回溯消息</span></span>
1422
+ <span className="dcl-trs-field-label"><strong>{t.autoCheckpointLabel}</strong>
1423
+ <span className="dcl-trs-field-desc">{t.autoCheckpointDescription}</span></span>
844
1424
  <span className="dcl-trs-field-control">
845
1425
  <select
846
1426
  value={value.turnCheckpointMode}
847
1427
  disabled={!writable || busy !== null}
848
1428
  onChange={(event) => { commitEnum('turnCheckpointMode', event.target.value) }}
849
1429
  >
850
- {(Object.keys(CHECKPOINT_MODE_LABELS) as TurnRewindSettingsValue['turnCheckpointMode'][]).map((option) => (
851
- <option key={option} value={option}>{CHECKPOINT_MODE_LABELS[option]}</option>
1430
+ {CHECKPOINT_MODES.map((option) => (
1431
+ <option key={option} value={option}>{t.checkpointModes[option]}</option>
852
1432
  ))}
853
1433
  </select>
854
- {userLayer.turnCheckpointMode !== undefined && <span className="dcl-trs-override">已覆盖</span>}
1434
+ {userLayer.turnCheckpointMode !== undefined && <span className="dcl-trs-override">{t.overridden}</span>}
855
1435
  {userLayer.turnCheckpointMode !== undefined && (
856
1436
  <Button variant="ghost" size="sm" disabled={!writable || busy !== null}
857
- onClick={() => { setFormError(null); void scope?.unset('turnCheckpointMode').catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>恢复默认</Button>
1437
+ onClick={() => { setFormError(null); void scope?.unset('turnCheckpointMode').catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>{t.resetToDefault}</Button>
858
1438
  )}
859
1439
  </span>
860
1440
  </div>
861
1441
  <div className="dcl-trs-field">
862
- <span className="dcl-trs-field-label"><strong>检查点信任策略</strong>
863
- <span className="dcl-trs-field-desc">快速信任 Git/stat 元数据;严格会逐一重读文件内容</span></span>
1442
+ <span className="dcl-trs-field-label"><strong>{t.trustLabel}</strong>
1443
+ <span className="dcl-trs-field-desc">{t.trustDescription}</span></span>
864
1444
  <span className="dcl-trs-field-control">
865
1445
  <select
866
1446
  value={value.turnCheckpointTrust}
867
1447
  disabled={!writable || busy !== null}
868
1448
  onChange={(event) => { commitEnum('turnCheckpointTrust', event.target.value) }}
869
1449
  >
870
- {(Object.keys(TRUST_LABELS) as TurnRewindSettingsValue['turnCheckpointTrust'][]).map((option) => (
871
- <option key={option} value={option}>{TRUST_LABELS[option]}</option>
1450
+ {TRUST_MODES.map((option) => (
1451
+ <option key={option} value={option}>{t.trustOptions[option]}</option>
872
1452
  ))}
873
1453
  </select>
874
- {userLayer.turnCheckpointTrust !== undefined && <span className="dcl-trs-override">已覆盖</span>}
1454
+ {userLayer.turnCheckpointTrust !== undefined && <span className="dcl-trs-override">{t.overridden}</span>}
875
1455
  {userLayer.turnCheckpointTrust !== undefined && (
876
1456
  <Button variant="ghost" size="sm" disabled={!writable || busy !== null}
877
- onClick={() => { setFormError(null); void scope?.unset('turnCheckpointTrust').catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>恢复默认</Button>
1457
+ onClick={() => { setFormError(null); void scope?.unset('turnCheckpointTrust').catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>{t.resetToDefault}</Button>
878
1458
  )}
879
1459
  </span>
880
1460
  </div>
881
1461
  {NUMBER_FIELDS.map((field) => (
882
- <div className="dcl-trs-field" key={field.key}>
883
- <span className="dcl-trs-field-label"><strong>{field.label}</strong>
884
- <span className="dcl-trs-field-desc">{field.description}</span></span>
1462
+ <div className="dcl-trs-field" key={field}>
1463
+ <span className="dcl-trs-field-label"><strong>{t.numberFields[field].label}</strong>
1464
+ <span className="dcl-trs-field-desc">{t.numberFields[field].description}</span></span>
885
1465
  <span className="dcl-trs-field-control">
886
1466
  <input
887
1467
  type="number" min={1} step={1}
888
- value={drafts[field.key] ?? String(value[field.key])}
1468
+ value={drafts[field] ?? String(value[field])}
889
1469
  disabled={!writable || busy !== null}
890
- onChange={(event) => { setDrafts((current) => ({ ...current, [field.key]: event.target.value })) }}
891
- onBlur={() => { commitNumber(field.key) }}
892
- onKeyDown={(event) => { if (event.key === 'Enter') commitNumber(field.key) }}
1470
+ onChange={(event) => { setDrafts((current) => ({ ...current, [field]: event.target.value })) }}
1471
+ onBlur={() => { commitNumber(field) }}
1472
+ onKeyDown={(event) => { if (event.key === 'Enter') commitNumber(field) }}
893
1473
  />
894
- {userLayer[field.key] !== undefined && <span className="dcl-trs-override">已覆盖</span>}
895
- {userLayer[field.key] !== undefined && (
1474
+ {userLayer[field] !== undefined && <span className="dcl-trs-override">{t.overridden}</span>}
1475
+ {userLayer[field] !== undefined && (
896
1476
  <Button variant="ghost" size="sm" disabled={!writable || busy !== null}
897
- onClick={() => { setFormError(null); void scope?.unset(field.key).catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>恢复默认</Button>
1477
+ onClick={() => { setFormError(null); void scope?.unset(field).catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>{t.resetToDefault}</Button>
898
1478
  )}
899
1479
  </span>
900
1480
  </div>
@@ -902,33 +1482,37 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
902
1482
  </>
903
1483
  )}
904
1484
  {formError !== null && <p className="dcl-trs-error">{formError}</p>}
905
- <p className="dcl-trs-storage">存储目录(在 cordis.patch.yml 中配置,不可在线修改):{manage?.storageDir ?? '…'}</p>
1485
+ <p className="dcl-trs-storage">{t.storageDir(manage?.storageDir ?? '…')}</p>
906
1486
  </section>
907
1487
  <section className="dcl-trs-section">
908
1488
  <div className="dcl-trs-section-title">
909
- <strong>检查点管理</strong>
1489
+ <strong>{t.manageSection}</strong>
910
1490
  <span className="dcl-trs-section-title-actions">
911
1491
  <Button variant="outline" size="sm" onClick={() => { void refreshManage() }} disabled={manageLoading}>
912
- {manageLoading ? '正在刷新…' : '刷新'}
1492
+ {manageLoading ? t.refreshing : t.refresh}
913
1493
  </Button>
914
1494
  {manage !== null && manage.workspaces.length > 0 && (
915
1495
  confirmClearAll
916
1496
  ? (
917
1497
  <>
918
- <Button variant="outline" size="sm" onClick={() => { setConfirmClearAll(false) }} disabled={busy !== null}>取消</Button>
1498
+ <Button variant="outline" size="sm" onClick={() => { setConfirmClearAll(false) }} disabled={busy !== null}>{t.cancel}</Button>
919
1499
  <Button variant="primary" size="sm" onClick={() => { void runManageAction({ action: 'clear-all' }, 'clear-all') }} disabled={busy !== null}>
920
- {busy === 'clear-all' ? '正在清理…' : '确认清空全部'}
1500
+ {busy === 'clear-all' ? t.clearing : t.confirmClearAll}
921
1501
  </Button>
922
1502
  </>
923
1503
  )
924
- : <Button variant="outline" size="sm" onClick={() => { setConfirmClearAll(true) }} disabled={busy !== null}>一键清空全部</Button>
1504
+ : <Button variant="outline" size="sm" onClick={() => { setConfirmClearAll(true) }} disabled={busy !== null}>{t.clearAll}</Button>
925
1505
  )}
926
1506
  </span>
927
1507
  </div>
928
1508
  <p className="dcl-trs-manage-total">
929
1509
  {manage === null
930
- ? '正在读取检查点占用…'
931
- : `共 ${String(manage.workspaces.length)} 个工作区,${String(manage.totalBytes >= 0 ? manage.workspaces.reduce((total, workspace) => total + workspace.restorePoints.length, 0) : 0)} 个检查点,约 ${formatBytes(manage.totalBytes)}(Git 原生检查点的实际磁盘占用以 Git 回收为准)。`}
1510
+ ? t.manageLoading
1511
+ : t.manageTotal(
1512
+ manage.workspaces.length,
1513
+ manage.totalBytes >= 0 ? manage.workspaces.reduce((total, workspace) => total + workspace.restorePoints.length, 0) : 0,
1514
+ formatBytes(manage.totalBytes),
1515
+ )}
932
1516
  </p>
933
1517
  {manageNotice !== null && <p className="dcl-trs-notice" data-warning={manageNotice.warning}>{manageNotice.message}</p>}
934
1518
  {manageError !== null && <p className="dcl-trs-error">{manageError}</p>}
@@ -936,15 +1520,15 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
936
1520
  <div className="dcl-trs-workspace" key={workspace.workspace}>
937
1521
  <div className="dcl-trs-workspace-head">
938
1522
  <span className="dcl-trs-workspace-path" title={workspace.workspace}>{workspace.workspace}</span>
939
- <span className="dcl-trs-workspace-meta">{String(workspace.restorePoints.length)} 个检查点 · {formatBytes(workspace.totalBytes)}</span>
940
- {workspace.recoveryCount > 0 && <span className="dcl-trs-badge">{String(workspace.recoveryCount)} 个恢复待处理</span>}
1523
+ <span className="dcl-trs-workspace-meta">{t.workspacePoints(workspace.restorePoints.length)} · {formatBytes(workspace.totalBytes)}</span>
1524
+ {workspace.recoveryCount > 0 && <span className="dcl-trs-badge">{t.pendingRecoveries(workspace.recoveryCount)}</span>}
941
1525
  <Button variant="ghost" size="sm" onClick={() => { toggleWorkspace(workspace.workspace) }}>
942
- {collapsed.has(workspace.workspace) ? '展开' : '收起'}
1526
+ {collapsed.has(workspace.workspace) ? t.expand : t.collapse}
943
1527
  </Button>
944
1528
  <Button variant="outline" size="sm"
945
1529
  onClick={() => { void runManageAction({ action: 'clear-workspace', workspace: workspace.workspace }, `clear:${workspace.workspace}`) }}
946
1530
  disabled={busy !== null || workspace.restorePoints.length === 0}>
947
- {busy === `clear:${workspace.workspace}` ? '正在清理…' : '清空此项目'}
1531
+ {busy === `clear:${workspace.workspace}` ? t.clearing : t.clearWorkspace}
948
1532
  </Button>
949
1533
  </div>
950
1534
  {!collapsed.has(workspace.workspace) && workspace.restorePoints.length > 0 && (
@@ -952,14 +1536,14 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
952
1536
  {workspace.restorePoints.map((point) => (
953
1537
  <li className="dcl-trs-point" key={point.id}>
954
1538
  <time>{formatTime(point.createdAt)}</time>
955
- <span className="dcl-trs-point-kind">{POINT_KIND_LABELS[point.kind] ?? point.kind}</span>
1539
+ <span className="dcl-trs-point-kind">{t.pointKinds[point.kind] ?? point.kind}</span>
956
1540
  <span className="dcl-trs-point-size">{formatBytes(point.totalBytes)}</span>
957
- <span className="dcl-trs-point-files">{String(point.fileCount)} 个文件</span>
1541
+ <span className="dcl-trs-point-files">{t.fileCount(point.fileCount)}</span>
958
1542
  {point.sessionId !== undefined && <code>{point.sessionId}</code>}
959
1543
  <Button variant="ghost" size="sm"
960
1544
  onClick={() => { void runManageAction({ action: 'delete', workspace: workspace.workspace, restorePointId: point.id }, `delete:${point.id}`) }}
961
1545
  disabled={busy !== null}>
962
- {busy === `delete:${point.id}` ? '正在删除…' : '删除'}
1546
+ {busy === `delete:${point.id}` ? t.deleting : t.remove}
963
1547
  </Button>
964
1548
  </li>
965
1549
  ))}
@@ -967,7 +1551,7 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
967
1551
  )}
968
1552
  </div>
969
1553
  ))}
970
- {manage !== null && manage.workspaces.length === 0 && <p className="dcl-trs-status">还没有任何已保存的检查点。</p>}
1554
+ {manage !== null && manage.workspaces.length === 0 && <p className="dcl-trs-status">{t.noCheckpoints}</p>}
971
1555
  </section>
972
1556
  </div>
973
1557
  )}
@@ -978,14 +1562,14 @@ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps):
978
1562
  function decodeManageOverview(value: unknown): ManageOverview {
979
1563
  const record = recordOf(value)
980
1564
  const workspacesValue = record.workspaces
981
- if (!Array.isArray(workspacesValue)) throw new Error('管理数据缺少 workspaces')
1565
+ if (!Array.isArray(workspacesValue)) throw new Error(uiText().manageMissingWorkspaces)
982
1566
  return {
983
1567
  storageDir: requiredString(record.storageDir, 'storageDir'),
984
1568
  totalBytes: requiredInteger(record.totalBytes, 'totalBytes'),
985
1569
  workspaces: workspacesValue.map((entry) => {
986
1570
  const workspace = recordOf(entry)
987
1571
  const pointsValue = workspace.restorePoints
988
- if (!Array.isArray(pointsValue)) throw new Error('管理数据缺少 restorePoints')
1572
+ if (!Array.isArray(pointsValue)) throw new Error(uiText().manageMissingRestorePoints)
989
1573
  return {
990
1574
  workspace: requiredString(workspace.workspace, 'workspace'),
991
1575
  totalBytes: requiredInteger(workspace.totalBytes, 'totalBytes'),
@@ -1012,7 +1596,7 @@ function decodeManageActionNotice(value: unknown): ManageActionNotice {
1012
1596
  const record = recordOf(value)
1013
1597
  const action = requiredString(record.action, 'action')
1014
1598
  if (action === 'clear-all') {
1015
- if (!Array.isArray(record.reports)) throw new Error('清理结果缺少 reports')
1599
+ if (!Array.isArray(record.reports)) throw new Error(uiText().clearMissingReports)
1016
1600
  const totals = record.reports.reduce((current, entry) => {
1017
1601
  const report = recordOf(entry)
1018
1602
  return {
@@ -1024,14 +1608,14 @@ function decodeManageActionNotice(value: unknown): ManageActionNotice {
1024
1608
  const warning = record.status === 'partial' || totals.retained > 0 || failures > 0
1025
1609
  return {
1026
1610
  warning,
1027
- message: `已删除 ${String(totals.deleted)} 个检查点${totals.retained > 0 ? `,${String(totals.retained)} 个受保护检查点未删除` : ''}${failures > 0 ? `,${String(failures)} 个工作区清理失败` : ''}。`,
1611
+ message: uiText().clearedAll(totals.deleted, totals.retained, failures),
1028
1612
  }
1029
1613
  }
1030
1614
  const deleted = requiredInteger(record.deletedRestorePoints, 'deletedRestorePoints')
1031
1615
  const retained = requiredInteger(record.retainedRestorePoints, 'retainedRestorePoints')
1032
1616
  return {
1033
1617
  warning: retained > 0,
1034
- message: `已删除 ${String(deleted)} 个检查点${retained > 0 ? `,${String(retained)} 个受保护检查点未删除` : ''}。`,
1618
+ message: uiText().cleared(deleted, retained),
1035
1619
  }
1036
1620
  }
1037
1621
 
@@ -1059,16 +1643,16 @@ function decodePreview(value: unknown): Preview {
1059
1643
  if (status === 'pending' || status === 'missing') return { status }
1060
1644
  if (status === 'skipped') return { status, reason: requiredString(record.reason, 'reason') }
1061
1645
  if (status === 'failed') return { status, error: requiredString(record.error, 'error') }
1062
- if (status !== 'ready') throw new Error(`未知回退状态:${status}`)
1646
+ if (status !== 'ready') throw new Error(uiText().unknownStatus(status))
1063
1647
  const changesValue = record.changes
1064
- if (!Array.isArray(changesValue)) throw new Error('回退预览缺少 changes')
1648
+ if (!Array.isArray(changesValue)) throw new Error(uiText().previewMissingChanges)
1065
1649
  const changes = changesValue.map((entry) => {
1066
1650
  const change = recordOf(entry)
1067
1651
  return { path: requiredString(change.path, 'path'), kind: requiredString(change.kind, 'kind') as ChangeKind }
1068
1652
  })
1069
1653
  const activeSessionIdsValue = record.activeSessionIds
1070
1654
  if (!Array.isArray(activeSessionIdsValue) || !activeSessionIdsValue.every(value => typeof value === 'string')) {
1071
- throw new Error('回退预览缺少 activeSessionIds')
1655
+ throw new Error(uiText().previewMissingActiveSessionIds)
1072
1656
  }
1073
1657
  return {
1074
1658
  status,
@@ -1185,17 +1769,22 @@ function samePortalTargets(
1185
1769
  })
1186
1770
  }
1187
1771
 
1188
- async function openSessionWithDraft(ctx: ClientContextLike, sessionId: string, promptText: string): Promise<void> {
1189
- let lastError: unknown = new Error('新对话还没有准备好')
1772
+ export async function openSessionWithDraft(ctx: ClientContextLike, sessionId: string, promptText: string): Promise<void> {
1773
+ if (typeof ctx.sessions.open === 'function') ctx.sessions.open(sessionId)
1774
+ else {
1775
+ const navigation = ctx.get?.('uiWorkspace') as { openSession(id: string): void } | undefined
1776
+ if (navigation === undefined) throw new Error(uiText().sessionNotReady)
1777
+ navigation.openSession(sessionId)
1778
+ }
1779
+ let lastError: unknown = new Error(uiText().sessionNotReady)
1190
1780
  for (let attempt = 0; attempt < 20; attempt += 1) {
1191
1781
  try {
1192
- ctx.sessions.open(sessionId)
1193
1782
  const scope = ctx.sessions.scope(sessionId)
1194
1783
  if (scope !== undefined) {
1195
1784
  ctx.conversation.input.for(scope).setDraft(promptText)
1196
1785
  return
1197
1786
  }
1198
- lastError = new Error('新对话还没有准备好')
1787
+ lastError = new Error(uiText().sessionNotReady)
1199
1788
  } catch (error) {
1200
1789
  lastError = error
1201
1790
  }
@@ -1221,7 +1810,7 @@ export async function responseJson(response: Response): Promise<unknown> {
1221
1810
  } catch {
1222
1811
  throw new RewindRequestError(
1223
1812
  response.ok ? 'REWIND_INVALID_RESPONSE' : 'REWIND_ENDPOINT_UNAVAILABLE',
1224
- `回退服务返回了无法解析的内容(HTTP ${String(response.status)})。`,
1813
+ uiText().unparsableResponse(String(response.status)),
1225
1814
  )
1226
1815
  }
1227
1816
  const record = value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -1232,11 +1821,11 @@ export async function responseJson(response: Response): Promise<unknown> {
1232
1821
  typeof record?.code === 'string'
1233
1822
  ? record.code
1234
1823
  : response.status === 404 ? 'REWIND_ENDPOINT_UNAVAILABLE' : 'REWIND_FAILED',
1235
- typeof record?.error === 'string' ? record.error : `请求失败:${String(response.status)}`,
1824
+ typeof record?.error === 'string' ? record.error : uiText().requestFailed(String(response.status)),
1236
1825
  )
1237
1826
  }
1238
1827
  if (value === undefined) {
1239
- throw new RewindRequestError('REWIND_INVALID_RESPONSE', '回退服务返回了空响应。')
1828
+ throw new RewindRequestError('REWIND_INVALID_RESPONSE', uiText().emptyResponse)
1240
1829
  }
1241
1830
  return value
1242
1831
  }
@@ -1248,22 +1837,22 @@ class RewindRequestError extends Error {
1248
1837
  }
1249
1838
 
1250
1839
  function recordOf(value: unknown): Record<string, unknown> {
1251
- if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('服务器返回了无效对象')
1840
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(uiText().invalidObject)
1252
1841
  return value as Record<string, unknown>
1253
1842
  }
1254
1843
 
1255
1844
  function requiredString(value: unknown, name: string): string {
1256
- if (typeof value !== 'string' || value === '') throw new Error(`${name} 无效`)
1845
+ if (typeof value !== 'string' || value === '') throw new Error(uiText().invalidField(name))
1257
1846
  return value
1258
1847
  }
1259
1848
 
1260
1849
  function requiredInteger(value: unknown, name: string): number {
1261
- if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} 无效`)
1850
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(uiText().invalidField(name))
1262
1851
  return value as number
1263
1852
  }
1264
1853
 
1265
1854
  function requiredBoolean(value: unknown, name: string): boolean {
1266
- if (typeof value !== 'boolean') throw new Error(`${name} 无效`)
1855
+ if (typeof value !== 'boolean') throw new Error(uiText().invalidField(name))
1267
1856
  return value
1268
1857
  }
1269
1858
 
@@ -1275,13 +1864,7 @@ function optionalRecordString(record: Record<string, unknown>, name: string): Re
1275
1864
 
1276
1865
  /** Describe the user-visible result of restoring one changed file. */
1277
1866
  export function fileRecoveryLabel(kind: ChangeKind): string {
1278
- switch (kind) {
1279
- case 'added': return '移除后来新增的文件'
1280
- case 'deleted': return '找回文件'
1281
- case 'modified': return '恢复之前的版本'
1282
- case 'mode-changed': return '恢复文件权限'
1283
- case 'type-changed': return '恢复之前的文件类型'
1284
- }
1867
+ return uiText().fileKinds[kind]
1285
1868
  }
1286
1869
 
1287
1870
  function RewindIcon({ size }: { readonly size: number }): ReactNode {
@@ -1294,25 +1877,7 @@ function RewindIcon({ size }: { readonly size: number }): ReactNode {
1294
1877
 
1295
1878
  function friendlyError(error: unknown): string {
1296
1879
  if (!(error instanceof RewindRequestError)) return messageOf(error)
1297
- switch (error.code) {
1298
- case 'REWIND_ENDPOINT_UNAVAILABLE': return '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。'
1299
- case 'REWIND_INVALID_RESPONSE': return '回退服务返回了无法解析的内容。请重启 DSH 后重试。'
1300
- case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1301
- case 'PLAN_STALE_WORKSPACE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1302
- case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
1303
- case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
1304
- case 'WORKSPACE_LOCKED': return '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。'
1305
- case 'WORKSPACE_CHANGED': return '这个项目目录已经不在原来的位置,旧的回退点无法再使用。'
1306
- case 'WORKSPACE_MODE_CHANGED': return '这个项目目录的工作区类型变了(例如从普通目录变成了 Git 仓库)。旧的回退点不再适用,请重新发送一条消息生成新的回退点。'
1307
- case 'HEAD_CHANGED': return '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。'
1308
- case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
1309
- case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
1310
- case 'RESTORE_POINT_NOT_FOUND': return '没有找到对应的文件状态,可能已被清理。'
1311
- case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。'
1312
- case 'RESTORE_FAILED_ROLLED_BACK': return '恢复未能完成,项目文件已自动还原到操作前的状态。'
1313
- case 'CONVERSATION_REWIND_FAILED': return '文件已恢复,但无法创建新对话;项目文件已自动还原。'
1314
- default: return error.message
1315
- }
1880
+ return uiText().errors[error.code] ?? error.message
1316
1881
  }
1317
1882
 
1318
1883
  /**
@@ -1326,15 +1891,9 @@ function friendlyError(error: unknown): string {
1326
1891
  export function describeCaptureNotice(ready: ReadyPreview | null): string | null {
1327
1892
  if (ready === null) return null
1328
1893
  const example = (ready.skipped ?? [])[0]?.path
1329
- if (ready.captureTruncated === 'snapshot-limit') {
1330
- return '本轮要保存的文件总量超过上限,剩下的文件没有纳入检查点。恢复时不会改动它们。'
1331
- }
1332
- if (ready.captureTruncated === 'file-limit') {
1333
- return '本轮文件数量超过上限,多出来的文件没有纳入检查点。恢复时不会改动它们。'
1334
- }
1335
- if (ready.skippedCount > 0) {
1336
- return `有 ${String(ready.skippedCount)} 个文件因为超过单文件大小上限或类型不受支持,没有纳入检查点${example === undefined ? '' : `(例如 ${example})`}。恢复时不会改动它们。`
1337
- }
1894
+ if (ready.captureTruncated === 'snapshot-limit') return uiText().captureSnapshotLimit
1895
+ if (ready.captureTruncated === 'file-limit') return uiText().captureFileLimit
1896
+ if (ready.skippedCount > 0) return uiText().captureSkipped(ready.skippedCount, example)
1338
1897
  return null
1339
1898
  }
1340
1899
 
@@ -1349,16 +1908,11 @@ export function describeCaptureNotice(ready: ReadyPreview | null): string | null
1349
1908
  */
1350
1909
  export function explainCheckpointSkip(reason: string): string {
1351
1910
  const code = /^\[([A-Z_]+)\]/.exec(reason)?.[1]
1352
- if (code === 'TURN_CHECKPOINT_TIMEOUT') {
1353
- return '这个项目目录太大,在检查点时间上限内没保存完文件快照,所以本轮没有回退点(消息本身没有受影响)。可以在插件设置里调大「检查点时间上限」,或在目录根用 .dsh-rewindignore 排除大目录(例如 node_modules、构建产物、数据集)。仍可只回溯消息。'
1354
- }
1355
- if (code === 'TURN_CHECKPOINT_DISABLED') {
1356
- return '自动文件检查点已在设置里关闭,本轮没有回退点。仍可只回溯消息。'
1357
- }
1358
- if (code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT') {
1359
- return '这一轮新增的内容超过检查点预算,没有保存文件快照。仍可只回溯消息。'
1360
- }
1361
- return `本轮没有保存文件检查点:${reason}仍可只回溯消息。`
1911
+ const t = uiText()
1912
+ if (code === 'TURN_CHECKPOINT_TIMEOUT') return t.skipTimeout
1913
+ if (code === 'TURN_CHECKPOINT_DISABLED') return t.skipDisabled
1914
+ if (code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT') return t.skipNewContentLimit
1915
+ return t.skipOther(reason)
1362
1916
  }
1363
1917
 
1364
1918
  /**
@@ -1372,23 +1926,22 @@ export function explainCheckpointSkip(reason: string): string {
1372
1926
  */
1373
1927
  export function explainCheckpointFailure(message: string): string {
1374
1928
  const code = /^\[([A-Z_]+)\]/.exec(message)?.[1]
1929
+ const t = uiText()
1375
1930
  switch (code) {
1376
1931
  case 'GIT_COMMAND_FAILED':
1377
- return /not a git repository/i.test(message)
1378
- ? '这个项目目录不是 Git 仓库,回退功能无法保存文件检查点。仍可只回溯消息。'
1379
- : `无法读取这个项目目录的 Git 状态:${message}仍可只回溯消息。`
1932
+ return /not a git repository/i.test(message) ? t.failureNotGitRepository : t.failureGitStatus(message)
1380
1933
  case 'FILE_TOO_LARGE':
1381
1934
  case 'SNAPSHOT_TOO_LARGE':
1382
1935
  case 'TOO_MANY_FILES':
1383
- return '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。'
1936
+ return t.failureSizeOrCount
1384
1937
  case 'UNSUPPORTED_FILE_TYPE':
1385
- return '项目目录里有无法保存的特殊文件(如 socket、设备文件、FIFO),没有保存文件检查点。仍可只回溯消息。'
1938
+ return t.failureUnsupportedType
1386
1939
  case 'IGNORE_FILE_INVALID':
1387
- return '项目目录里的 .dsh-rewindignore 内容无效,没有保存文件检查点。修正该文件后可重新发送消息。'
1940
+ return t.failureIgnoreFileInvalid
1388
1941
  case 'INVALID_PATH':
1389
- return '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。'
1942
+ return t.failureInvalidPath
1390
1943
  default:
1391
- return `没能保存这条消息发送前的文件:${message}仍可只回溯消息。`
1944
+ return t.failureOther(message)
1392
1945
  }
1393
1946
  }
1394
1947