ccmanager 0.0.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.
- package/README.md +85 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.js +57 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +24 -0
- package/dist/components/App.d.ts +3 -0
- package/dist/components/App.js +228 -0
- package/dist/components/ConfigureShortcuts.d.ts +6 -0
- package/dist/components/ConfigureShortcuts.js +139 -0
- package/dist/components/Confirmation.d.ts +12 -0
- package/dist/components/Confirmation.js +42 -0
- package/dist/components/DeleteWorktree.d.ts +7 -0
- package/dist/components/DeleteWorktree.js +116 -0
- package/dist/components/Menu.d.ts +9 -0
- package/dist/components/Menu.js +154 -0
- package/dist/components/MergeWorktree.d.ts +7 -0
- package/dist/components/MergeWorktree.js +142 -0
- package/dist/components/NewWorktree.d.ts +7 -0
- package/dist/components/NewWorktree.js +49 -0
- package/dist/components/Session.d.ts +10 -0
- package/dist/components/Session.js +121 -0
- package/dist/constants/statusIcons.d.ts +18 -0
- package/dist/constants/statusIcons.js +27 -0
- package/dist/services/sessionManager.d.ts +16 -0
- package/dist/services/sessionManager.js +190 -0
- package/dist/services/sessionManager.test.d.ts +1 -0
- package/dist/services/sessionManager.test.js +99 -0
- package/dist/services/shortcutManager.d.ts +17 -0
- package/dist/services/shortcutManager.js +167 -0
- package/dist/services/worktreeService.d.ts +24 -0
- package/dist/services/worktreeService.js +220 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.js +21 -0
- package/dist/utils/promptDetector.d.ts +1 -0
- package/dist/utils/promptDetector.js +20 -0
- package/dist/utils/promptDetector.test.d.ts +1 -0
- package/dist/utils/promptDetector.test.js +81 -0
- package/package.json +70 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
4
|
+
sessions: Map<string, Session>;
|
|
5
|
+
private waitingWithBottomBorder;
|
|
6
|
+
private stripAnsi;
|
|
7
|
+
detectSessionState(cleanData: string, currentState: SessionState, sessionId: string): SessionState;
|
|
8
|
+
constructor();
|
|
9
|
+
createSession(worktreePath: string): Session;
|
|
10
|
+
private setupBackgroundHandler;
|
|
11
|
+
getSession(worktreePath: string): Session | undefined;
|
|
12
|
+
setSessionActive(worktreePath: string, active: boolean): void;
|
|
13
|
+
destroySession(worktreePath: string): void;
|
|
14
|
+
getAllSessions(): Session[];
|
|
15
|
+
destroy(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawn } from 'node-pty';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { includesPromptBoxBottomBorder } from '../utils/promptDetector.js';
|
|
4
|
+
export class SessionManager extends EventEmitter {
|
|
5
|
+
stripAnsi(str) {
|
|
6
|
+
// Remove all ANSI escape sequences including cursor movement, color codes, etc.
|
|
7
|
+
return str
|
|
8
|
+
.replace(/\x1b\[[0-9;]*m/g, '') // Color codes (including 24-bit)
|
|
9
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
10
|
+
.replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
|
|
11
|
+
.replace(/\x1b[PX^_].*?\x1b\\/g, '') // DCS/PM/APC/SOS sequences
|
|
12
|
+
.replace(/\x1b\[\?[0-9;]*[hl]/g, '') // Private mode sequences
|
|
13
|
+
.replace(/\x1b[>=]/g, '') // Other escape sequences
|
|
14
|
+
.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '') // Control characters except newline (\x0A)
|
|
15
|
+
.replace(/\r/g, '') // Carriage returns
|
|
16
|
+
.replace(/^[0-9;]+m/gm, '') // Orphaned color codes at line start
|
|
17
|
+
.replace(/[0-9]+;[0-9]+;[0-9;]+m/g, ''); // Orphaned 24-bit color codes
|
|
18
|
+
}
|
|
19
|
+
detectSessionState(cleanData, currentState, sessionId) {
|
|
20
|
+
const hasBottomBorder = includesPromptBoxBottomBorder(cleanData);
|
|
21
|
+
const hasWaitingPrompt = cleanData.includes('│ Do you want');
|
|
22
|
+
const wasWaitingWithBottomBorder = this.waitingWithBottomBorder.get(sessionId) || false;
|
|
23
|
+
let newState = currentState;
|
|
24
|
+
// Check if current state is waiting and this is just a prompt box bottom border
|
|
25
|
+
if (hasWaitingPrompt) {
|
|
26
|
+
newState = 'waiting_input';
|
|
27
|
+
// Check if this same data also contains the bottom border
|
|
28
|
+
if (hasBottomBorder) {
|
|
29
|
+
this.waitingWithBottomBorder.set(sessionId, true);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
this.waitingWithBottomBorder.set(sessionId, false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (currentState === 'waiting_input' &&
|
|
36
|
+
hasBottomBorder &&
|
|
37
|
+
!hasWaitingPrompt &&
|
|
38
|
+
!wasWaitingWithBottomBorder) {
|
|
39
|
+
// Keep the waiting state and mark that we've seen the bottom border
|
|
40
|
+
newState = 'waiting_input';
|
|
41
|
+
this.waitingWithBottomBorder.set(sessionId, true);
|
|
42
|
+
}
|
|
43
|
+
else if (cleanData.toLowerCase().includes('esc to interrupt')) {
|
|
44
|
+
newState = 'busy';
|
|
45
|
+
this.waitingWithBottomBorder.set(sessionId, false);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
newState = 'idle';
|
|
49
|
+
this.waitingWithBottomBorder.set(sessionId, false);
|
|
50
|
+
}
|
|
51
|
+
return newState;
|
|
52
|
+
}
|
|
53
|
+
constructor() {
|
|
54
|
+
super();
|
|
55
|
+
Object.defineProperty(this, "sessions", {
|
|
56
|
+
enumerable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
writable: true,
|
|
59
|
+
value: void 0
|
|
60
|
+
});
|
|
61
|
+
Object.defineProperty(this, "waitingWithBottomBorder", {
|
|
62
|
+
enumerable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
writable: true,
|
|
65
|
+
value: new Map()
|
|
66
|
+
});
|
|
67
|
+
this.sessions = new Map();
|
|
68
|
+
}
|
|
69
|
+
createSession(worktreePath) {
|
|
70
|
+
// Check if session already exists
|
|
71
|
+
const existing = this.sessions.get(worktreePath);
|
|
72
|
+
if (existing) {
|
|
73
|
+
return existing;
|
|
74
|
+
}
|
|
75
|
+
const id = `session-${Date.now()}-${Math.random()
|
|
76
|
+
.toString(36)
|
|
77
|
+
.substr(2, 9)}`;
|
|
78
|
+
const ptyProcess = spawn('claude', [], {
|
|
79
|
+
name: 'xterm-color',
|
|
80
|
+
cols: process.stdout.columns || 80,
|
|
81
|
+
rows: process.stdout.rows || 24,
|
|
82
|
+
cwd: worktreePath,
|
|
83
|
+
env: process.env,
|
|
84
|
+
});
|
|
85
|
+
const session = {
|
|
86
|
+
id,
|
|
87
|
+
worktreePath,
|
|
88
|
+
process: ptyProcess,
|
|
89
|
+
state: 'busy', // Session starts as busy when created
|
|
90
|
+
output: [],
|
|
91
|
+
outputHistory: [],
|
|
92
|
+
lastActivity: new Date(),
|
|
93
|
+
isActive: false,
|
|
94
|
+
};
|
|
95
|
+
// Set up persistent background data handler for state detection
|
|
96
|
+
this.setupBackgroundHandler(session);
|
|
97
|
+
this.sessions.set(worktreePath, session);
|
|
98
|
+
this.emit('sessionCreated', session);
|
|
99
|
+
return session;
|
|
100
|
+
}
|
|
101
|
+
setupBackgroundHandler(session) {
|
|
102
|
+
// This handler always runs for all data
|
|
103
|
+
session.process.onData((data) => {
|
|
104
|
+
// Store in output history as Buffer
|
|
105
|
+
const buffer = Buffer.from(data, 'utf8');
|
|
106
|
+
session.outputHistory.push(buffer);
|
|
107
|
+
// Limit memory usage - keep max 10MB of output history
|
|
108
|
+
const MAX_HISTORY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
109
|
+
let totalSize = session.outputHistory.reduce((sum, buf) => sum + buf.length, 0);
|
|
110
|
+
while (totalSize > MAX_HISTORY_SIZE && session.outputHistory.length > 0) {
|
|
111
|
+
const removed = session.outputHistory.shift();
|
|
112
|
+
if (removed) {
|
|
113
|
+
totalSize -= removed.length;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Also store for state detection
|
|
117
|
+
session.output.push(data);
|
|
118
|
+
// Keep only last 100 chunks for state detection
|
|
119
|
+
if (session.output.length > 100) {
|
|
120
|
+
session.output.shift();
|
|
121
|
+
}
|
|
122
|
+
session.lastActivity = new Date();
|
|
123
|
+
// Strip ANSI codes for pattern matching
|
|
124
|
+
const cleanData = this.stripAnsi(data);
|
|
125
|
+
// Skip state monitoring if cleanData is empty
|
|
126
|
+
if (!cleanData.trim()) {
|
|
127
|
+
// Only emit data events when session is active
|
|
128
|
+
if (session.isActive) {
|
|
129
|
+
this.emit('sessionData', session, data);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Detect state based on the new data
|
|
134
|
+
const oldState = session.state;
|
|
135
|
+
const newState = this.detectSessionState(cleanData, oldState, session.id);
|
|
136
|
+
// Update state if changed
|
|
137
|
+
if (newState !== oldState) {
|
|
138
|
+
session.state = newState;
|
|
139
|
+
this.emit('sessionStateChanged', session);
|
|
140
|
+
}
|
|
141
|
+
// Only emit data events when session is active
|
|
142
|
+
if (session.isActive) {
|
|
143
|
+
this.emit('sessionData', session, data);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
session.process.onExit(() => {
|
|
147
|
+
// Update state to idle before destroying
|
|
148
|
+
session.state = 'idle';
|
|
149
|
+
this.emit('sessionStateChanged', session);
|
|
150
|
+
this.destroySession(session.worktreePath);
|
|
151
|
+
this.emit('sessionExit', session);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
getSession(worktreePath) {
|
|
155
|
+
return this.sessions.get(worktreePath);
|
|
156
|
+
}
|
|
157
|
+
setSessionActive(worktreePath, active) {
|
|
158
|
+
const session = this.sessions.get(worktreePath);
|
|
159
|
+
if (session) {
|
|
160
|
+
session.isActive = active;
|
|
161
|
+
// If becoming active, emit a restore event with the output history
|
|
162
|
+
if (active && session.outputHistory.length > 0) {
|
|
163
|
+
this.emit('sessionRestore', session);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
destroySession(worktreePath) {
|
|
168
|
+
const session = this.sessions.get(worktreePath);
|
|
169
|
+
if (session) {
|
|
170
|
+
try {
|
|
171
|
+
session.process.kill();
|
|
172
|
+
}
|
|
173
|
+
catch (_error) {
|
|
174
|
+
// Process might already be dead
|
|
175
|
+
}
|
|
176
|
+
this.sessions.delete(worktreePath);
|
|
177
|
+
this.waitingWithBottomBorder.delete(session.id);
|
|
178
|
+
this.emit('sessionDestroyed', session);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
getAllSessions() {
|
|
182
|
+
return Array.from(this.sessions.values());
|
|
183
|
+
}
|
|
184
|
+
destroy() {
|
|
185
|
+
// Clean up all sessions
|
|
186
|
+
for (const worktreePath of this.sessions.keys()) {
|
|
187
|
+
this.destroySession(worktreePath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { SessionManager } from './sessionManager.js';
|
|
3
|
+
// Mock the promptDetector module
|
|
4
|
+
vi.mock('../utils/promptDetector.js', () => ({
|
|
5
|
+
includesPromptBoxBottomBorder: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { includesPromptBoxBottomBorder } from '../utils/promptDetector.js';
|
|
8
|
+
describe('SessionManager', () => {
|
|
9
|
+
let sessionManager;
|
|
10
|
+
const mockSessionId = 'test-session-123';
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
sessionManager = new SessionManager();
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
describe('detectSessionState', () => {
|
|
16
|
+
it('should detect waiting_input state when "Do you want" prompt is present', () => {
|
|
17
|
+
const cleanData = '│ Do you want to continue?';
|
|
18
|
+
const currentState = 'idle';
|
|
19
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
20
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
21
|
+
expect(newState).toBe('waiting_input');
|
|
22
|
+
});
|
|
23
|
+
it('should set waitingWithBottomBorder when waiting prompt and bottom border are both present', () => {
|
|
24
|
+
const cleanData = '│ Do you want to continue?\n└───────────────────────┘';
|
|
25
|
+
const currentState = 'idle';
|
|
26
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
27
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
28
|
+
expect(newState).toBe('waiting_input');
|
|
29
|
+
// The internal map should have been set to true
|
|
30
|
+
});
|
|
31
|
+
it('should maintain waiting_input state when bottom border appears after waiting prompt', () => {
|
|
32
|
+
const cleanData = '└───────────────────────┘';
|
|
33
|
+
const currentState = 'waiting_input';
|
|
34
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
35
|
+
// First call to set up the waiting state without bottom border
|
|
36
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
37
|
+
sessionManager.detectSessionState('│ Do you want to continue?', 'idle', mockSessionId);
|
|
38
|
+
// Now test the bottom border appearing
|
|
39
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
40
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
41
|
+
expect(newState).toBe('waiting_input');
|
|
42
|
+
});
|
|
43
|
+
it('should detect busy state when "esc to interrupt" is present', () => {
|
|
44
|
+
const cleanData = 'Processing... Press ESC to interrupt';
|
|
45
|
+
const currentState = 'idle';
|
|
46
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
47
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
48
|
+
expect(newState).toBe('busy');
|
|
49
|
+
});
|
|
50
|
+
it('should detect idle state when no specific patterns are found', () => {
|
|
51
|
+
const cleanData = 'Some regular output text';
|
|
52
|
+
const currentState = 'busy';
|
|
53
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
54
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
55
|
+
expect(newState).toBe('idle');
|
|
56
|
+
});
|
|
57
|
+
it('should handle case-insensitive "esc to interrupt" detection', () => {
|
|
58
|
+
const cleanData = 'Running task... PRESS ESC TO INTERRUPT';
|
|
59
|
+
const currentState = 'idle';
|
|
60
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
61
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
62
|
+
expect(newState).toBe('busy');
|
|
63
|
+
});
|
|
64
|
+
it('should not change from waiting_input when bottom border was already seen', () => {
|
|
65
|
+
const cleanData = '└───────────────────────┘';
|
|
66
|
+
const currentState = 'waiting_input';
|
|
67
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
68
|
+
// First, simulate seeing waiting prompt with bottom border
|
|
69
|
+
sessionManager.detectSessionState('│ Do you want to continue?\n└───────────────────────┘', 'idle', mockSessionId);
|
|
70
|
+
// Now another bottom border appears
|
|
71
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
72
|
+
expect(newState).toBe('idle'); // Should change to idle since we already saw the bottom border
|
|
73
|
+
});
|
|
74
|
+
it('should clear waitingWithBottomBorder flag when transitioning to busy', () => {
|
|
75
|
+
const cleanData = 'Processing... Press ESC to interrupt';
|
|
76
|
+
const currentState = 'waiting_input';
|
|
77
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
78
|
+
// First set up waiting state with bottom border
|
|
79
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
80
|
+
sessionManager.detectSessionState('│ Do you want to continue?\n└───────────────────────┘', 'idle', mockSessionId);
|
|
81
|
+
// Now transition to busy
|
|
82
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
83
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
84
|
+
expect(newState).toBe('busy');
|
|
85
|
+
});
|
|
86
|
+
it('should clear waitingWithBottomBorder flag when transitioning to idle', () => {
|
|
87
|
+
const cleanData = 'Task completed successfully';
|
|
88
|
+
const currentState = 'waiting_input';
|
|
89
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
90
|
+
// First set up waiting state with bottom border
|
|
91
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
92
|
+
sessionManager.detectSessionState('│ Do you want to continue?\n└───────────────────────┘', 'idle', mockSessionId);
|
|
93
|
+
// Now transition to idle
|
|
94
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
95
|
+
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
96
|
+
expect(newState).toBe('idle');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ShortcutKey, ShortcutConfig } from '../types/index.js';
|
|
2
|
+
import { Key } from 'ink';
|
|
3
|
+
export declare class ShortcutManager {
|
|
4
|
+
private shortcuts;
|
|
5
|
+
private configPath;
|
|
6
|
+
private reservedKeys;
|
|
7
|
+
constructor();
|
|
8
|
+
private loadShortcuts;
|
|
9
|
+
private validateShortcut;
|
|
10
|
+
private isReservedKey;
|
|
11
|
+
saveShortcuts(shortcuts: ShortcutConfig): boolean;
|
|
12
|
+
getShortcuts(): ShortcutConfig;
|
|
13
|
+
matchesShortcut(shortcutName: keyof ShortcutConfig, input: string, key: Key): boolean;
|
|
14
|
+
getShortcutDisplay(shortcutName: keyof ShortcutConfig): string;
|
|
15
|
+
getShortcutCode(shortcut: ShortcutKey): string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare const shortcutManager: ShortcutManager;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { DEFAULT_SHORTCUTS, } from '../types/index.js';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
export class ShortcutManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
Object.defineProperty(this, "shortcuts", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
value: void 0
|
|
12
|
+
});
|
|
13
|
+
Object.defineProperty(this, "configPath", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
value: void 0
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(this, "reservedKeys", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: [
|
|
24
|
+
{ ctrl: true, key: 'c' },
|
|
25
|
+
{ ctrl: true, key: 'd' },
|
|
26
|
+
{ key: 'escape' }, // Ctrl+[ is equivalent to Escape
|
|
27
|
+
{ ctrl: true, key: '[' },
|
|
28
|
+
]
|
|
29
|
+
});
|
|
30
|
+
// Use platform-specific config directory
|
|
31
|
+
const configDir = process.platform === 'win32'
|
|
32
|
+
? path.join(process.env['APPDATA'] || os.homedir(), 'ccmanager')
|
|
33
|
+
: path.join(os.homedir(), '.config', 'ccmanager');
|
|
34
|
+
this.configPath = path.join(configDir, 'shortcuts.json');
|
|
35
|
+
this.shortcuts = this.loadShortcuts();
|
|
36
|
+
}
|
|
37
|
+
loadShortcuts() {
|
|
38
|
+
try {
|
|
39
|
+
if (fs.existsSync(this.configPath)) {
|
|
40
|
+
const data = fs.readFileSync(this.configPath, 'utf8');
|
|
41
|
+
const loaded = JSON.parse(data);
|
|
42
|
+
// Validate loaded shortcuts
|
|
43
|
+
const validated = {
|
|
44
|
+
returnToMenu: this.validateShortcut(loaded.returnToMenu) ||
|
|
45
|
+
DEFAULT_SHORTCUTS.returnToMenu,
|
|
46
|
+
cancel: this.validateShortcut(loaded.cancel) || DEFAULT_SHORTCUTS.cancel,
|
|
47
|
+
};
|
|
48
|
+
return validated;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error('Failed to load shortcuts:', error);
|
|
53
|
+
}
|
|
54
|
+
return { ...DEFAULT_SHORTCUTS };
|
|
55
|
+
}
|
|
56
|
+
validateShortcut(shortcut) {
|
|
57
|
+
if (!shortcut || typeof shortcut !== 'object') {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const s = shortcut;
|
|
61
|
+
if (!s['key'] || typeof s['key'] !== 'string') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const validShortcut = {
|
|
65
|
+
key: s['key'],
|
|
66
|
+
ctrl: !!s['ctrl'],
|
|
67
|
+
alt: !!s['alt'],
|
|
68
|
+
shift: !!s['shift'],
|
|
69
|
+
};
|
|
70
|
+
// Check if it's a reserved key
|
|
71
|
+
if (this.isReservedKey(validShortcut)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
// Ensure at least one modifier key is used (except for special keys like escape)
|
|
75
|
+
if (validShortcut.key !== 'escape' &&
|
|
76
|
+
!validShortcut.ctrl &&
|
|
77
|
+
!validShortcut.alt &&
|
|
78
|
+
!validShortcut.shift) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return validShortcut;
|
|
82
|
+
}
|
|
83
|
+
isReservedKey(shortcut) {
|
|
84
|
+
return this.reservedKeys.some(reserved => reserved.key === shortcut.key &&
|
|
85
|
+
reserved.ctrl === shortcut.ctrl &&
|
|
86
|
+
reserved.alt === shortcut.alt &&
|
|
87
|
+
reserved.shift === shortcut.shift);
|
|
88
|
+
}
|
|
89
|
+
saveShortcuts(shortcuts) {
|
|
90
|
+
// Validate all shortcuts
|
|
91
|
+
const validated = {
|
|
92
|
+
returnToMenu: this.validateShortcut(shortcuts.returnToMenu) ||
|
|
93
|
+
this.shortcuts.returnToMenu,
|
|
94
|
+
cancel: this.validateShortcut(shortcuts.cancel) || this.shortcuts.cancel,
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
const dir = path.dirname(this.configPath);
|
|
98
|
+
if (!fs.existsSync(dir)) {
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
fs.writeFileSync(this.configPath, JSON.stringify(validated, null, 2));
|
|
102
|
+
this.shortcuts = validated;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error('Failed to save shortcuts:', error);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
getShortcuts() {
|
|
111
|
+
return { ...this.shortcuts };
|
|
112
|
+
}
|
|
113
|
+
matchesShortcut(shortcutName, input, key) {
|
|
114
|
+
const shortcut = this.shortcuts[shortcutName];
|
|
115
|
+
if (!shortcut)
|
|
116
|
+
return false;
|
|
117
|
+
// Handle escape key specially
|
|
118
|
+
if (shortcut.key === 'escape') {
|
|
119
|
+
return key.escape === true;
|
|
120
|
+
}
|
|
121
|
+
// Check modifiers
|
|
122
|
+
if (shortcut.ctrl !== key.ctrl)
|
|
123
|
+
return false;
|
|
124
|
+
// Note: ink's Key type doesn't support alt or shift modifiers
|
|
125
|
+
// so we can't check them here. For now, we'll only support ctrl modifier
|
|
126
|
+
if (shortcut.alt || shortcut.shift)
|
|
127
|
+
return false;
|
|
128
|
+
// Check key
|
|
129
|
+
return input.toLowerCase() === shortcut.key.toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
getShortcutDisplay(shortcutName) {
|
|
132
|
+
const shortcut = this.shortcuts[shortcutName];
|
|
133
|
+
if (!shortcut)
|
|
134
|
+
return '';
|
|
135
|
+
const parts = [];
|
|
136
|
+
if (shortcut.ctrl)
|
|
137
|
+
parts.push('Ctrl');
|
|
138
|
+
if (shortcut.alt)
|
|
139
|
+
parts.push('Alt');
|
|
140
|
+
if (shortcut.shift)
|
|
141
|
+
parts.push('Shift');
|
|
142
|
+
// Format special keys
|
|
143
|
+
let keyDisplay = shortcut.key;
|
|
144
|
+
if (keyDisplay === 'escape')
|
|
145
|
+
keyDisplay = 'Esc';
|
|
146
|
+
else if (keyDisplay.length === 1)
|
|
147
|
+
keyDisplay = keyDisplay.toUpperCase();
|
|
148
|
+
parts.push(keyDisplay);
|
|
149
|
+
return parts.join('+');
|
|
150
|
+
}
|
|
151
|
+
getShortcutCode(shortcut) {
|
|
152
|
+
// Convert shortcut to terminal code for raw stdin handling
|
|
153
|
+
if (!shortcut.ctrl || shortcut.alt || shortcut.shift) {
|
|
154
|
+
return null; // Only support Ctrl+key for raw codes
|
|
155
|
+
}
|
|
156
|
+
const key = shortcut.key.toLowerCase();
|
|
157
|
+
if (key.length !== 1)
|
|
158
|
+
return null;
|
|
159
|
+
// Convert Ctrl+letter to ASCII control code
|
|
160
|
+
const code = key.charCodeAt(0) - 96; // 'a' = 1, 'b' = 2, etc.
|
|
161
|
+
if (code >= 1 && code <= 26) {
|
|
162
|
+
return String.fromCharCode(code);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export const shortcutManager = new ShortcutManager();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Worktree } from '../types/index.js';
|
|
2
|
+
export declare class WorktreeService {
|
|
3
|
+
private rootPath;
|
|
4
|
+
constructor(rootPath?: string);
|
|
5
|
+
getWorktrees(): Worktree[];
|
|
6
|
+
private getCurrentBranch;
|
|
7
|
+
isGitRepository(): boolean;
|
|
8
|
+
createWorktree(worktreePath: string, branch: string): {
|
|
9
|
+
success: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
};
|
|
12
|
+
deleteWorktree(worktreePath: string): {
|
|
13
|
+
success: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
mergeWorktree(sourceBranch: string, targetBranch: string, useRebase?: boolean): {
|
|
17
|
+
success: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
};
|
|
20
|
+
deleteWorktreeByBranch(branch: string): {
|
|
21
|
+
success: boolean;
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|