clawt 3.4.6 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/README.md +0 -4
  3. package/dist/index.js +583 -314
  4. package/dist/postinstall.js +37 -2
  5. package/docs/alias.md +7 -1
  6. package/docs/completion.md +1 -1
  7. package/docs/config.md +4 -3
  8. package/docs/cover-validate.md +4 -3
  9. package/docs/create.md +28 -12
  10. package/docs/home.md +12 -8
  11. package/docs/init.md +16 -9
  12. package/docs/list.md +13 -7
  13. package/docs/merge.md +12 -12
  14. package/docs/remove.md +24 -13
  15. package/docs/reset.md +6 -4
  16. package/docs/resume.md +3 -4
  17. package/docs/status.md +75 -30
  18. package/docs/sync.md +26 -26
  19. package/docs/validate.md +13 -7
  20. package/package.json +1 -1
  21. package/src/commands/merge.ts +20 -5
  22. package/src/commands/tasks.ts +51 -0
  23. package/src/constants/ai-prompts.ts +14 -0
  24. package/src/constants/config.ts +9 -0
  25. package/src/constants/index.ts +4 -0
  26. package/src/constants/interactive-panel.ts +6 -0
  27. package/src/constants/messages/index.ts +4 -2
  28. package/src/constants/messages/interactive-panel.ts +12 -0
  29. package/src/constants/messages/merge.ts +15 -0
  30. package/src/constants/messages/tasks.ts +9 -0
  31. package/src/constants/tasks-template.ts +28 -0
  32. package/src/index.ts +2 -0
  33. package/src/types/command.ts +8 -0
  34. package/src/types/config.ts +4 -0
  35. package/src/types/index.ts +1 -1
  36. package/src/utils/conflict-resolver.ts +170 -0
  37. package/src/utils/formatter.ts +19 -0
  38. package/src/utils/git-branch.ts +116 -0
  39. package/src/utils/git-core.ts +417 -0
  40. package/src/utils/git-worktree.ts +40 -0
  41. package/src/utils/git.ts +3 -521
  42. package/src/utils/index.ts +7 -2
  43. package/src/utils/interactive-panel-render.ts +12 -6
  44. package/src/utils/interactive-panel-state.ts +137 -0
  45. package/src/utils/interactive-panel.ts +44 -188
  46. package/src/utils/keyboard-controller.ts +48 -0
  47. package/src/utils/ui-prompts.ts +240 -0
  48. package/src/utils/worktree-matcher.ts +21 -251
  49. package/tests/unit/commands/merge.test.ts +59 -3
  50. package/tests/unit/commands/tasks.test.ts +153 -0
  51. package/tests/unit/utils/conflict-resolver.test.ts +250 -0
  52. package/tests/unit/utils/formatter.test.ts +26 -1
  53. package/src/constants/messages.ts +0 -179
@@ -0,0 +1,137 @@
1
+ import type { StatusResult } from '../types/index.js';
2
+ import { buildDisplayOrder, calculateVisibleRows, buildGroupedWorktreeLines } from './interactive-panel-render.js';
3
+
4
+ /**
5
+ * 面板状态管理器
6
+ * 负责维护面板的数据状态、滚动偏移和选中项
7
+ */
8
+ export class PanelStateManager {
9
+ /** 当前状态数据 */
10
+ private statusResult: StatusResult | null = null;
11
+ /** 当前选中的显示位置索引(对应 displayOrder 数组的下标) */
12
+ private selectedDisplayIndex: number = 0;
13
+ /** 显示顺序到原始索引的映射(按日期分组后的排列顺序) */
14
+ private displayOrder: number[] = [];
15
+ /** 滚动偏移(基于行数) */
16
+ private scrollOffset: number = 0;
17
+
18
+ /**
19
+ * 更新状态数据
20
+ * @param {StatusResult} newStatus - 新的状态数据
21
+ * @param {string} [previousBranch] - 刷新前选中的分支名
22
+ */
23
+ updateData(newStatus: StatusResult, previousBranch?: string): void {
24
+ this.statusResult = newStatus;
25
+ this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
26
+
27
+ if (previousBranch && this.displayOrder.length > 0) {
28
+ const newDisplayIndex = this.displayOrder.findIndex(
29
+ (origIdx) => this.statusResult!.worktrees[origIdx]?.branch === previousBranch,
30
+ );
31
+ if (newDisplayIndex >= 0) {
32
+ this.selectedDisplayIndex = newDisplayIndex;
33
+ } else {
34
+ this.selectedDisplayIndex = Math.min(this.selectedDisplayIndex, Math.max(0, this.displayOrder.length - 1));
35
+ }
36
+ } else {
37
+ this.selectedDisplayIndex = 0;
38
+ }
39
+ }
40
+
41
+ /** 获取当前状态数据 */
42
+ getStatusResult(): StatusResult | null {
43
+ return this.statusResult;
44
+ }
45
+
46
+ /** 获取当前选中的原始索引 */
47
+ getSelectedOriginalIndex(): number {
48
+ return this.displayOrder[this.selectedDisplayIndex] ?? -1;
49
+ }
50
+
51
+ /** 获取当前滚动偏移 */
52
+ getScrollOffset(): number {
53
+ return this.scrollOffset;
54
+ }
55
+
56
+ /**
57
+ * 向上导航
58
+ * @returns {boolean} 是否发生变化
59
+ */
60
+ navigateUp(): boolean {
61
+ if (!this.statusResult || this.displayOrder.length === 0) return false;
62
+
63
+ if (this.selectedDisplayIndex > 0) {
64
+ this.selectedDisplayIndex--;
65
+ this.adjustScrollForSelection();
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * 向下导航
73
+ * @returns {boolean} 是否发生变化
74
+ */
75
+ navigateDown(): boolean {
76
+ if (!this.statusResult || this.displayOrder.length === 0) return false;
77
+
78
+ if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
79
+ this.selectedDisplayIndex++;
80
+ this.adjustScrollForSelection();
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * 获取当前选中的分支名
88
+ * @returns {string | null} 分支名
89
+ */
90
+ getSelectedBranch(): string | null {
91
+ const originalIndex = this.getSelectedOriginalIndex();
92
+ if (originalIndex === -1 || !this.statusResult) return null;
93
+ return this.statusResult.worktrees[originalIndex]?.branch || null;
94
+ }
95
+
96
+ /**
97
+ * 调整滚动位置以确保选中项在可见区域内
98
+ */
99
+ adjustScrollForSelection(): void {
100
+ if (!this.statusResult || this.displayOrder.length === 0) return;
101
+
102
+ const originalIndex = this.getSelectedOriginalIndex();
103
+ const rows = process.stdout.rows || 24;
104
+ const visibleRows = calculateVisibleRows(rows);
105
+ const panelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
106
+
107
+ // 找到选中 worktree 对应的第一行和最后一行
108
+ let firstLine = -1;
109
+ let lastLine = -1;
110
+ for (let i = 0; i < panelLines.length; i++) {
111
+ if (panelLines[i].worktreeIndex === originalIndex) {
112
+ if (firstLine === -1) firstLine = i;
113
+ lastLine = i;
114
+ }
115
+ }
116
+
117
+ if (firstLine === -1) return;
118
+
119
+ // 向前查找该 worktree 所属日期分组的分隔线行
120
+ let groupStart = firstLine;
121
+ while (groupStart > 0 && panelLines[groupStart - 1].type === 'separator') {
122
+ groupStart--;
123
+ }
124
+
125
+ if (groupStart < this.scrollOffset) {
126
+ this.scrollOffset = groupStart;
127
+ }
128
+
129
+ if (lastLine >= this.scrollOffset + visibleRows) {
130
+ this.scrollOffset = lastLine - visibleRows + 1;
131
+ }
132
+
133
+ if (this.scrollOffset > groupStart) {
134
+ this.scrollOffset = groupStart;
135
+ }
136
+ }
137
+ }
@@ -20,9 +20,11 @@ import {
20
20
  } from '../constants/index.js';
21
21
  import { PANEL_NOT_TTY, PANEL_PRESS_ENTER_TO_RETURN } from '../constants/messages/index.js';
22
22
  import { runCommandInherited } from './shell.js';
23
- import { buildPanelFrame, buildGroupedWorktreeLines, buildDisplayOrder, calculateVisibleRows } from './interactive-panel-render.js';
23
+ import { buildPanelFrame } from './interactive-panel-render.js';
24
24
  import { truncateToTerminalWidth } from './progress-render.js';
25
25
  import type { StatusResult } from '../types/index.js';
26
+ import { KeyboardController } from './keyboard-controller.js';
27
+ import { PanelStateManager } from './interactive-panel-state.js';
26
28
 
27
29
  /**
28
30
  * 交互式状态面板
@@ -31,14 +33,10 @@ import type { StatusResult } from '../types/index.js';
31
33
  * 参照 ProgressRenderer 的生命周期模式实现。
32
34
  */
33
35
  export class InteractivePanel {
34
- /** 当前状态数据 */
35
- private statusResult: StatusResult | null;
36
- /** 当前选中的显示位置索引(对应 displayOrder 数组的下标) */
37
- private selectedDisplayIndex: number;
38
- /** 显示顺序到原始索引的映射(按日期分组后的排列顺序) */
39
- private displayOrder: number[];
40
- /** 滚动偏移(基于行数) */
41
- private scrollOffset: number;
36
+ /** 状态管理器 */
37
+ private stateManager: PanelStateManager;
38
+ /** 键盘控制器 */
39
+ private keyboardController: KeyboardController;
42
40
  /** 数据刷新定时器引用 */
43
41
  private refreshTimer: ReturnType<typeof setInterval> | null;
44
42
  /** 倒计时定时器引用 */
@@ -53,8 +51,6 @@ export class InteractivePanel {
53
51
  private resizeHandler: (() => void) | null;
54
52
  /** exit 兜底处理器 */
55
53
  private exitHandler: (() => void) | null;
56
- /** stdin 数据处理器引用(用于清理) */
57
- private stdinDataHandler: ((data: Buffer) => void) | null;
58
54
  /** 操作锁(防止操作期间响应按键) */
59
55
  private isOperating: boolean;
60
56
  /** Promise resolve 函数(stop 时调用以完成 start 返回的 Promise) */
@@ -67,10 +63,8 @@ export class InteractivePanel {
67
63
  * @param {() => StatusResult} collectStatusFn - 数据收集函数
68
64
  */
69
65
  constructor(collectStatusFn: () => StatusResult) {
70
- this.statusResult = null;
71
- this.selectedDisplayIndex = 0;
72
- this.displayOrder = [];
73
- this.scrollOffset = 0;
66
+ this.stateManager = new PanelStateManager();
67
+ this.keyboardController = new KeyboardController(this.handleKeypress.bind(this));
74
68
  this.refreshTimer = null;
75
69
  this.countdownTimer = null;
76
70
  this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
@@ -78,7 +72,6 @@ export class InteractivePanel {
78
72
  this.isTTY = !!process.stdout.isTTY;
79
73
  this.resizeHandler = null;
80
74
  this.exitHandler = null;
81
- this.stdinDataHandler = null;
82
75
  this.isOperating = false;
83
76
  this.resolveStart = null;
84
77
  this.collectStatusFn = collectStatusFn;
@@ -100,14 +93,13 @@ export class InteractivePanel {
100
93
  this.resolveStart = resolve;
101
94
 
102
95
  // 收集初始数据
103
- this.statusResult = this.collectStatusFn();
104
- this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
96
+ this.stateManager.updateData(this.collectStatusFn());
105
97
 
106
98
  // 初始化终端
107
99
  this.initTerminal();
108
100
 
109
101
  // 启动键盘监听
110
- this.startKeyboardListener();
102
+ this.keyboardController.start();
111
103
 
112
104
  // 启动自动刷新
113
105
  this.startAutoRefresh();
@@ -128,7 +120,7 @@ export class InteractivePanel {
128
120
  this.clearTimers();
129
121
 
130
122
  // 停止键盘监听
131
- this.stopKeyboardListener();
123
+ this.keyboardController.stop();
132
124
 
133
125
  // 恢复终端
134
126
  this.restoreTerminal();
@@ -163,6 +155,8 @@ export class InteractivePanel {
163
155
  // 监听终端宽度变化,立即触发重绘
164
156
  this.resizeHandler = () => {
165
157
  if (!this.stopped && !this.isOperating) {
158
+ // 在调整大小时也调整滚动
159
+ this.stateManager.adjustScrollForSelection();
166
160
  this.render();
167
161
  }
168
162
  };
@@ -186,36 +180,6 @@ export class InteractivePanel {
186
180
  process.stdout.write(ALT_SCREEN_LEAVE);
187
181
  }
188
182
 
189
- /**
190
- * 启动键盘监听
191
- * 将 stdin 设为 raw 模式以捕获每个按键
192
- */
193
- private startKeyboardListener(): void {
194
- if (process.stdin.isTTY) {
195
- process.stdin.setRawMode(true);
196
- }
197
- process.stdin.resume();
198
-
199
- this.stdinDataHandler = (data: Buffer) => {
200
- this.handleKeypress(data);
201
- };
202
- process.stdin.on('data', this.stdinDataHandler);
203
- }
204
-
205
- /**
206
- * 停止键盘监听,恢复 stdin 状态
207
- */
208
- private stopKeyboardListener(): void {
209
- if (this.stdinDataHandler) {
210
- process.stdin.removeListener('data', this.stdinDataHandler);
211
- this.stdinDataHandler = null;
212
- }
213
- if (process.stdin.isTTY) {
214
- process.stdin.setRawMode(false);
215
- }
216
- process.stdin.pause();
217
- }
218
-
219
183
  /**
220
184
  * 处理键盘输入
221
185
  * @param {Buffer} data - 按键数据
@@ -234,13 +198,17 @@ export class InteractivePanel {
234
198
 
235
199
  // 方向键上
236
200
  if (str === KEY_ARROW_UP) {
237
- this.navigateUp();
201
+ if (this.stateManager.navigateUp()) {
202
+ this.render();
203
+ }
238
204
  return;
239
205
  }
240
206
 
241
207
  // 方向键下
242
208
  if (str === KEY_ARROW_DOWN) {
243
- this.navigateDown();
209
+ if (this.stateManager.navigateDown()) {
210
+ this.render();
211
+ }
244
212
  return;
245
213
  }
246
214
 
@@ -288,85 +256,6 @@ export class InteractivePanel {
288
256
  }
289
257
  }
290
258
 
291
- /**
292
- * 向上导航,选中显示顺序中的上一个 worktree
293
- */
294
- private navigateUp(): void {
295
- if (!this.statusResult || this.displayOrder.length === 0) return;
296
-
297
- if (this.selectedDisplayIndex > 0) {
298
- this.selectedDisplayIndex--;
299
- this.adjustScrollForSelection();
300
- this.render();
301
- }
302
- }
303
-
304
- /**
305
- * 向下导航,选中显示顺序中的下一个 worktree
306
- */
307
- private navigateDown(): void {
308
- if (!this.statusResult || this.displayOrder.length === 0) return;
309
-
310
- if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
311
- this.selectedDisplayIndex++;
312
- this.adjustScrollForSelection();
313
- this.render();
314
- }
315
- }
316
-
317
- /**
318
- * 获取当前选中的原始 worktree 索引
319
- * @returns {number} 原始 worktrees 数组中的索引
320
- */
321
- private getSelectedOriginalIndex(): number {
322
- return this.displayOrder[this.selectedDisplayIndex];
323
- }
324
-
325
- /**
326
- * 调整滚动偏移以确保选中项在可见区域内
327
- */
328
- private adjustScrollForSelection(): void {
329
- if (!this.statusResult || this.displayOrder.length === 0) return;
330
-
331
- const originalIndex = this.getSelectedOriginalIndex();
332
- const rows = process.stdout.rows || 24;
333
- const visibleRows = calculateVisibleRows(rows);
334
- const panelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
335
-
336
- // 找到选中 worktree 对应的第一行和最后一行
337
- let firstLine = -1;
338
- let lastLine = -1;
339
- for (let i = 0; i < panelLines.length; i++) {
340
- if (panelLines[i].worktreeIndex === originalIndex) {
341
- if (firstLine === -1) firstLine = i;
342
- lastLine = i;
343
- }
344
- }
345
-
346
- if (firstLine === -1) return;
347
-
348
- // 向前查找该 worktree 所属日期分组的分隔线行,滚动时一并显示
349
- let groupStart = firstLine;
350
- while (groupStart > 0 && panelLines[groupStart - 1].type === 'separator') {
351
- groupStart--;
352
- }
353
-
354
- // 如果选中项(含日期分隔线)在可见区域之上,向上滚动
355
- if (groupStart < this.scrollOffset) {
356
- this.scrollOffset = groupStart;
357
- }
358
-
359
- // 如果选中项的最后一行在可见区域之下,向下滚动
360
- if (lastLine >= this.scrollOffset + visibleRows) {
361
- this.scrollOffset = lastLine - visibleRows + 1;
362
- }
363
-
364
- // 终端极小时向下滚动可能把日期分组标题推出屏幕,优先保证分组标题可见
365
- if (this.scrollOffset > groupStart) {
366
- this.scrollOffset = groupStart;
367
- }
368
- }
369
-
370
259
  /**
371
260
  * 启动自动刷新:数据刷新定时器 + 倒计时定时器
372
261
  */
@@ -412,15 +301,13 @@ export class InteractivePanel {
412
301
  if (this.stopped || this.isOperating) return;
413
302
 
414
303
  // 记录当前选中分支名
415
- const originalIndex = this.displayOrder[this.selectedDisplayIndex];
416
- const previousBranch = this.statusResult?.worktrees[originalIndex]?.branch;
304
+ const previousBranch = this.stateManager.getSelectedBranch();
417
305
 
418
- // 重新收集数据
419
- this.statusResult = this.collectStatusFn();
420
- this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
306
+ // 重新收集数据并更新状态
307
+ this.stateManager.updateData(this.collectStatusFn(), previousBranch || undefined);
421
308
 
422
- // 恢复选中位置
423
- this.restoreSelection(previousBranch);
309
+ // 在重绘前必须确保滚动状态正常
310
+ this.stateManager.adjustScrollForSelection();
424
311
 
425
312
  // 重置倒计时
426
313
  this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
@@ -428,44 +315,21 @@ export class InteractivePanel {
428
315
  this.render();
429
316
  }
430
317
 
431
- /**
432
- * 按分支名恢复选中位置(基于显示顺序)
433
- * @param {string | undefined} previousBranch - 之前选中的分支名
434
- */
435
- private restoreSelection(previousBranch: string | undefined): void {
436
- if (!this.statusResult || !previousBranch || this.displayOrder.length === 0) {
437
- this.selectedDisplayIndex = 0;
438
- return;
439
- }
440
-
441
- // 在显示顺序中查找之前选中的分支
442
- const newDisplayIndex = this.displayOrder.findIndex(
443
- (origIdx) => this.statusResult!.worktrees[origIdx]?.branch === previousBranch,
444
- );
445
- if (newDisplayIndex >= 0) {
446
- this.selectedDisplayIndex = newDisplayIndex;
447
- } else {
448
- // 分支已不存在,调整到安全范围
449
- this.selectedDisplayIndex = Math.min(this.selectedDisplayIndex, Math.max(0, this.displayOrder.length - 1));
450
- }
451
-
452
- this.adjustScrollForSelection();
453
- }
454
-
455
318
  /**
456
319
  * 渲染一帧面板内容
457
320
  * 使用同步输出防止闪烁
458
321
  */
459
322
  private render(): void {
460
- if (this.stopped || this.isOperating || !this.statusResult) return;
323
+ const statusResult = this.stateManager.getStatusResult();
324
+ if (this.stopped || this.isOperating || !statusResult) return;
461
325
 
462
326
  const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
463
327
  const rows = process.stdout.rows || 24;
464
328
 
465
329
  const frameLines = buildPanelFrame(
466
- this.statusResult,
467
- this.getSelectedOriginalIndex(),
468
- this.scrollOffset,
330
+ statusResult,
331
+ this.stateManager.getSelectedOriginalIndex(),
332
+ this.stateManager.getScrollOffset(),
469
333
  rows,
470
334
  cols,
471
335
  this.refreshCountdown,
@@ -491,7 +355,8 @@ export class InteractivePanel {
491
355
  * @param {() => void} action - 要执行的操作
492
356
  */
493
357
  private async executeOperation(action: () => void): Promise<void> {
494
- if (!this.statusResult || this.displayOrder.length === 0) return;
358
+ const statusResult = this.stateManager.getStatusResult();
359
+ if (!statusResult || this.stateManager.getSelectedOriginalIndex() === -1) return;
495
360
 
496
361
  this.isOperating = true;
497
362
 
@@ -502,7 +367,7 @@ export class InteractivePanel {
502
367
  this.restoreTerminal();
503
368
 
504
369
  // 恢复 stdin 以便子命令交互
505
- this.stopKeyboardListener();
370
+ this.keyboardController.stop();
506
371
 
507
372
  // 执行操作
508
373
  action();
@@ -515,7 +380,7 @@ export class InteractivePanel {
515
380
 
516
381
  // 重新进入面板模式
517
382
  this.initTerminal();
518
- this.startKeyboardListener();
383
+ this.keyboardController.start();
519
384
 
520
385
  this.isOperating = false;
521
386
 
@@ -527,53 +392,44 @@ export class InteractivePanel {
527
392
  this.render();
528
393
  }
529
394
 
530
- /**
531
- * 获取当前选中的分支名
532
- * @returns {string} 当前选中的分支名
533
- */
534
- private getSelectedBranch(): string {
535
- const originalIndex = this.getSelectedOriginalIndex();
536
- return this.statusResult!.worktrees[originalIndex].branch;
537
- }
538
-
539
395
  /**
540
396
  * 执行验证操作
541
397
  */
542
398
  private handleValidate(): void {
543
- const branch = this.getSelectedBranch();
544
- runCommandInherited(`clawt validate -b ${branch}`);
399
+ const branch = this.stateManager.getSelectedBranch();
400
+ if (branch) runCommandInherited(`clawt validate -b ${branch}`);
545
401
  }
546
402
 
547
403
  /**
548
404
  * 执行合并操作
549
405
  */
550
406
  private handleMerge(): void {
551
- const branch = this.getSelectedBranch();
552
- runCommandInherited(`clawt merge -b ${branch}`);
407
+ const branch = this.stateManager.getSelectedBranch();
408
+ if (branch) runCommandInherited(`clawt merge -b ${branch}`);
553
409
  }
554
410
 
555
411
  /**
556
412
  * 执行删除操作
557
413
  */
558
414
  private handleDelete(): void {
559
- const branch = this.getSelectedBranch();
560
- runCommandInherited(`clawt remove -b ${branch}`);
415
+ const branch = this.stateManager.getSelectedBranch();
416
+ if (branch) runCommandInherited(`clawt remove -b ${branch}`);
561
417
  }
562
418
 
563
419
  /**
564
420
  * 执行恢复操作
565
421
  */
566
422
  private handleResume(): void {
567
- const branch = this.getSelectedBranch();
568
- runCommandInherited(`clawt resume -b ${branch}`);
423
+ const branch = this.stateManager.getSelectedBranch();
424
+ if (branch) runCommandInherited(`clawt resume -b ${branch}`);
569
425
  }
570
426
 
571
427
  /**
572
428
  * 执行同步操作
573
429
  */
574
430
  private handleSync(): void {
575
- const branch = this.getSelectedBranch();
576
- runCommandInherited(`clawt sync -b ${branch}`);
431
+ const branch = this.stateManager.getSelectedBranch();
432
+ if (branch) runCommandInherited(`clawt sync -b ${branch}`);
577
433
  }
578
434
 
579
435
  /**
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 键盘事件控制器
3
+ * 负责终端键盘事件的绑定与解除
4
+ */
5
+ export class KeyboardController {
6
+ /** stdin 数据处理器引用(用于清理) */
7
+ private stdinDataHandler: ((data: Buffer) => void) | null = null;
8
+ /** 按键回调函数 */
9
+ private onKeypress: (data: Buffer) => void;
10
+
11
+ /**
12
+ * 创建键盘事件控制器
13
+ * @param {(data: Buffer) => void} onKeypress - 按键回调函数
14
+ */
15
+ constructor(onKeypress: (data: Buffer) => void) {
16
+ this.onKeypress = onKeypress;
17
+ }
18
+
19
+ /**
20
+ * 启动键盘监听
21
+ * 将 stdin 设为 raw 模式以捕获每个按键
22
+ */
23
+ start(): void {
24
+ if (process.stdin.isTTY) {
25
+ process.stdin.setRawMode(true);
26
+ }
27
+ process.stdin.resume();
28
+
29
+ this.stdinDataHandler = (data: Buffer) => {
30
+ this.onKeypress(data);
31
+ };
32
+ process.stdin.on('data', this.stdinDataHandler);
33
+ }
34
+
35
+ /**
36
+ * 停止键盘监听,恢复 stdin 状态
37
+ */
38
+ stop(): void {
39
+ if (this.stdinDataHandler) {
40
+ process.stdin.removeListener('data', this.stdinDataHandler);
41
+ this.stdinDataHandler = null;
42
+ }
43
+ if (process.stdin.isTTY) {
44
+ process.stdin.setRawMode(false);
45
+ }
46
+ process.stdin.pause();
47
+ }
48
+ }