ccmanager 0.1.1 → 0.1.3
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 +33 -6
- package/dist/components/App.js +6 -6
- package/dist/components/Configuration.d.ts +6 -0
- package/dist/components/Configuration.js +49 -0
- package/dist/components/ConfigureHooks.d.ts +6 -0
- package/dist/components/ConfigureHooks.js +133 -0
- package/dist/components/Menu.js +4 -4
- package/dist/components/Session.js +9 -0
- package/dist/services/configurationManager.d.ts +17 -0
- package/dist/services/configurationManager.js +115 -0
- package/dist/services/sessionManager.d.ts +1 -0
- package/dist/services/sessionManager.js +34 -0
- package/dist/services/shortcutManager.d.ts +0 -3
- package/dist/services/shortcutManager.js +11 -59
- package/dist/services/worktreeService.js +6 -1
- package/dist/types/index.d.ts +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ https://github.com/user-attachments/assets/a6d80e73-dc06-4ef8-849d-e3857f6c7024
|
|
|
11
11
|
- Visual status indicators for session states (busy, waiting, idle)
|
|
12
12
|
- Create, merge, and delete worktrees from within the app
|
|
13
13
|
- Configurable keyboard shortcuts
|
|
14
|
+
- Status change hooks for automation and notifications
|
|
14
15
|
|
|
15
16
|
## Why CCManager over Claude Squad?
|
|
16
17
|
|
|
@@ -76,26 +77,38 @@ The arguments are applied to all Claude Code sessions started by CCManager.
|
|
|
76
77
|
|
|
77
78
|
You can customize keyboard shortcuts in two ways:
|
|
78
79
|
|
|
79
|
-
1. **Through the UI**: Select "Configure Shortcuts" from the main menu
|
|
80
|
-
2. **Configuration file**: Edit `~/.config/ccmanager/shortcuts.json`
|
|
80
|
+
1. **Through the UI**: Select "Configuration" → "Configure Shortcuts" from the main menu
|
|
81
|
+
2. **Configuration file**: Edit `~/.config/ccmanager/config.json` (or legacy `~/.config/ccmanager/shortcuts.json`)
|
|
81
82
|
|
|
82
83
|
Example configuration:
|
|
83
84
|
```json
|
|
85
|
+
// config.json (new format)
|
|
86
|
+
{
|
|
87
|
+
"shortcuts": {
|
|
88
|
+
"returnToMenu": {
|
|
89
|
+
"ctrl": true,
|
|
90
|
+
"key": "r"
|
|
91
|
+
},
|
|
92
|
+
"cancel": {
|
|
93
|
+
"key": "escape"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// shortcuts.json (legacy format, still supported)
|
|
84
99
|
{
|
|
85
100
|
"returnToMenu": {
|
|
86
101
|
"ctrl": true,
|
|
87
102
|
"key": "r"
|
|
88
103
|
},
|
|
89
|
-
"exitApp": {
|
|
90
|
-
"ctrl": true,
|
|
91
|
-
"key": "x"
|
|
92
|
-
},
|
|
93
104
|
"cancel": {
|
|
94
105
|
"key": "escape"
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
108
|
```
|
|
98
109
|
|
|
110
|
+
Note: Shortcuts from `shortcuts.json` will be automatically migrated to `config.json` on first use.
|
|
111
|
+
|
|
99
112
|
### Restrictions
|
|
100
113
|
|
|
101
114
|
- Shortcuts must use a modifier key (Ctrl) except for special keys like Escape
|
|
@@ -104,6 +117,20 @@ Example configuration:
|
|
|
104
117
|
- Ctrl+D
|
|
105
118
|
- Ctrl+[ (equivalent to Escape)
|
|
106
119
|
|
|
120
|
+
## Status Change Hooks
|
|
121
|
+
|
|
122
|
+
CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
|
|
123
|
+
|
|
124
|
+
### Overview
|
|
125
|
+
|
|
126
|
+
Status hooks allow you to:
|
|
127
|
+
- Get notified when Claude needs your input
|
|
128
|
+
- Track time spent in different states
|
|
129
|
+
- Trigger automations based on session activity
|
|
130
|
+
- Integrate with notification systems like [noti](https://github.com/variadico/noti)
|
|
131
|
+
|
|
132
|
+
For detailed setup instructions, see [docs/state-hooks.md](docs/state-hooks.md).
|
|
133
|
+
|
|
107
134
|
## Development
|
|
108
135
|
|
|
109
136
|
```bash
|
package/dist/components/App.js
CHANGED
|
@@ -5,7 +5,7 @@ import Session from './Session.js';
|
|
|
5
5
|
import NewWorktree from './NewWorktree.js';
|
|
6
6
|
import DeleteWorktree from './DeleteWorktree.js';
|
|
7
7
|
import MergeWorktree from './MergeWorktree.js';
|
|
8
|
-
import
|
|
8
|
+
import Configuration from './Configuration.js';
|
|
9
9
|
import { SessionManager } from '../services/sessionManager.js';
|
|
10
10
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
11
11
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
@@ -62,9 +62,9 @@ const App = () => {
|
|
|
62
62
|
setView('merge-worktree');
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
-
// Check if this is the
|
|
66
|
-
if (worktree.path === '
|
|
67
|
-
setView('
|
|
65
|
+
// Check if this is the configuration option
|
|
66
|
+
if (worktree.path === 'CONFIGURATION') {
|
|
67
|
+
setView('configuration');
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
// Check if this is the exit application option
|
|
@@ -220,8 +220,8 @@ const App = () => {
|
|
|
220
220
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
221
221
|
React.createElement(Text, { color: "green" }, "Merging worktrees...")));
|
|
222
222
|
}
|
|
223
|
-
if (view === '
|
|
224
|
-
return React.createElement(
|
|
223
|
+
if (view === 'configuration') {
|
|
224
|
+
return React.createElement(Configuration, { onComplete: handleReturnToMenu });
|
|
225
225
|
}
|
|
226
226
|
return null;
|
|
227
227
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
import ConfigureShortcuts from './ConfigureShortcuts.js';
|
|
5
|
+
import ConfigureHooks from './ConfigureHooks.js';
|
|
6
|
+
const Configuration = ({ onComplete }) => {
|
|
7
|
+
const [view, setView] = useState('menu');
|
|
8
|
+
const menuItems = [
|
|
9
|
+
{
|
|
10
|
+
label: '⌨ Configure Shortcuts',
|
|
11
|
+
value: 'shortcuts',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
label: '🔧 Configure Status Hooks',
|
|
15
|
+
value: 'hooks',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: '← Back to Main Menu',
|
|
19
|
+
value: 'back',
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
const handleSelect = (item) => {
|
|
23
|
+
if (item.value === 'back') {
|
|
24
|
+
onComplete();
|
|
25
|
+
}
|
|
26
|
+
else if (item.value === 'shortcuts') {
|
|
27
|
+
setView('shortcuts');
|
|
28
|
+
}
|
|
29
|
+
else if (item.value === 'hooks') {
|
|
30
|
+
setView('hooks');
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const handleSubMenuComplete = () => {
|
|
34
|
+
setView('menu');
|
|
35
|
+
};
|
|
36
|
+
if (view === 'shortcuts') {
|
|
37
|
+
return React.createElement(ConfigureShortcuts, { onComplete: handleSubMenuComplete });
|
|
38
|
+
}
|
|
39
|
+
if (view === 'hooks') {
|
|
40
|
+
return React.createElement(ConfigureHooks, { onComplete: handleSubMenuComplete });
|
|
41
|
+
}
|
|
42
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
43
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
44
|
+
React.createElement(Text, { bold: true, color: "green" }, "Configuration")),
|
|
45
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
46
|
+
React.createElement(Text, { dimColor: true }, "Select a configuration option:")),
|
|
47
|
+
React.createElement(SelectInput, { items: menuItems, onSelect: handleSelect, isFocused: true })));
|
|
48
|
+
};
|
|
49
|
+
export default Configuration;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
import { configurationManager } from '../services/configurationManager.js';
|
|
6
|
+
const STATUS_LABELS = {
|
|
7
|
+
idle: 'Idle',
|
|
8
|
+
busy: 'Busy',
|
|
9
|
+
waiting_input: 'Waiting for Input',
|
|
10
|
+
};
|
|
11
|
+
const ConfigureHooks = ({ onComplete }) => {
|
|
12
|
+
const [view, setView] = useState('menu');
|
|
13
|
+
const [selectedStatus, setSelectedStatus] = useState('idle');
|
|
14
|
+
const [hooks, setHooks] = useState({});
|
|
15
|
+
const [currentCommand, setCurrentCommand] = useState('');
|
|
16
|
+
const [currentEnabled, setCurrentEnabled] = useState(false);
|
|
17
|
+
const [showSaveMessage, setShowSaveMessage] = useState(false);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setHooks(configurationManager.getStatusHooks());
|
|
20
|
+
}, []);
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (key.escape) {
|
|
23
|
+
if (view === 'edit') {
|
|
24
|
+
setView('menu');
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
onComplete();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else if (key.tab && view === 'edit') {
|
|
31
|
+
toggleEnabled();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const getMenuItems = () => {
|
|
35
|
+
const items = [];
|
|
36
|
+
// Add status hook items
|
|
37
|
+
['idle', 'busy', 'waiting_input'].forEach(status => {
|
|
38
|
+
const hook = hooks[status];
|
|
39
|
+
const enabled = hook?.enabled ? '✓' : '✗';
|
|
40
|
+
const command = hook?.command || '(not set)';
|
|
41
|
+
items.push({
|
|
42
|
+
label: `${STATUS_LABELS[status]}: ${enabled} ${command}`,
|
|
43
|
+
value: status,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
items.push({
|
|
47
|
+
label: '─────────────',
|
|
48
|
+
value: 'separator',
|
|
49
|
+
});
|
|
50
|
+
items.push({
|
|
51
|
+
label: '💾 Save and Return',
|
|
52
|
+
value: 'save',
|
|
53
|
+
});
|
|
54
|
+
items.push({
|
|
55
|
+
label: '← Cancel',
|
|
56
|
+
value: 'cancel',
|
|
57
|
+
});
|
|
58
|
+
return items;
|
|
59
|
+
};
|
|
60
|
+
const handleMenuSelect = (item) => {
|
|
61
|
+
if (item.value === 'save') {
|
|
62
|
+
configurationManager.setStatusHooks(hooks);
|
|
63
|
+
setShowSaveMessage(true);
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
onComplete();
|
|
66
|
+
}, 1000);
|
|
67
|
+
}
|
|
68
|
+
else if (item.value === 'cancel') {
|
|
69
|
+
onComplete();
|
|
70
|
+
}
|
|
71
|
+
else if (item.value !== 'separator') {
|
|
72
|
+
const status = item.value;
|
|
73
|
+
setSelectedStatus(status);
|
|
74
|
+
const hook = hooks[status];
|
|
75
|
+
setCurrentCommand(hook?.command || '');
|
|
76
|
+
setCurrentEnabled(hook?.enabled ?? true); // Default to true if not set
|
|
77
|
+
setView('edit');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const handleCommandSubmit = (value) => {
|
|
81
|
+
setHooks(prev => ({
|
|
82
|
+
...prev,
|
|
83
|
+
[selectedStatus]: {
|
|
84
|
+
command: value,
|
|
85
|
+
enabled: currentEnabled,
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
setView('menu');
|
|
89
|
+
};
|
|
90
|
+
const toggleEnabled = () => {
|
|
91
|
+
setCurrentEnabled(prev => !prev);
|
|
92
|
+
};
|
|
93
|
+
if (showSaveMessage) {
|
|
94
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
95
|
+
React.createElement(Text, { color: "green" }, "\u2713 Configuration saved successfully!")));
|
|
96
|
+
}
|
|
97
|
+
if (view === 'edit') {
|
|
98
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
99
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
100
|
+
React.createElement(Text, { bold: true, color: "green" },
|
|
101
|
+
"Configure ",
|
|
102
|
+
STATUS_LABELS[selectedStatus],
|
|
103
|
+
" Hook")),
|
|
104
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
105
|
+
React.createElement(Text, null,
|
|
106
|
+
"Command to execute when status changes to",
|
|
107
|
+
' ',
|
|
108
|
+
STATUS_LABELS[selectedStatus],
|
|
109
|
+
":")),
|
|
110
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
111
|
+
React.createElement(TextInput, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" })),
|
|
112
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
113
|
+
React.createElement(Text, null,
|
|
114
|
+
"Enabled: ",
|
|
115
|
+
currentEnabled ? '✓' : '✗',
|
|
116
|
+
" (Press Tab to toggle)")),
|
|
117
|
+
React.createElement(Box, { marginTop: 1 },
|
|
118
|
+
React.createElement(Text, { dimColor: true }, "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE,")),
|
|
119
|
+
React.createElement(Box, null,
|
|
120
|
+
React.createElement(Text, { dimColor: true }, "CCMANAGER_WORKTREE, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID")),
|
|
121
|
+
React.createElement(Box, { marginTop: 1 },
|
|
122
|
+
React.createElement(Text, { dimColor: true }, "Press Enter to save, Tab to toggle enabled, Esc to cancel"))));
|
|
123
|
+
}
|
|
124
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
125
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
126
|
+
React.createElement(Text, { bold: true, color: "green" }, "Configure Status Change Hooks")),
|
|
127
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
128
|
+
React.createElement(Text, { dimColor: true }, "Set commands to run when Claude Code session status changes:")),
|
|
129
|
+
React.createElement(SelectInput, { items: getMenuItems(), onSelect: handleMenuSelect, isFocused: true }),
|
|
130
|
+
React.createElement(Box, { marginTop: 1 },
|
|
131
|
+
React.createElement(Text, { dimColor: true }, "Press Esc to go back"))));
|
|
132
|
+
};
|
|
133
|
+
export default ConfigureHooks;
|
package/dist/components/Menu.js
CHANGED
|
@@ -67,8 +67,8 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
67
67
|
value: 'delete-worktree',
|
|
68
68
|
});
|
|
69
69
|
menuItems.push({
|
|
70
|
-
label: `${MENU_ICONS.CONFIGURE_SHORTCUTS}
|
|
71
|
-
value: '
|
|
70
|
+
label: `${MENU_ICONS.CONFIGURE_SHORTCUTS} Configuration`,
|
|
71
|
+
value: 'configuration',
|
|
72
72
|
});
|
|
73
73
|
menuItems.push({
|
|
74
74
|
label: `${MENU_ICONS.EXIT} Exit`,
|
|
@@ -107,10 +107,10 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
107
107
|
hasSession: false,
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
|
-
else if (item.value === '
|
|
110
|
+
else if (item.value === 'configuration') {
|
|
111
111
|
// Handle in parent component - use special marker
|
|
112
112
|
onSelectWorktree({
|
|
113
|
-
path: '
|
|
113
|
+
path: 'CONFIGURATION',
|
|
114
114
|
branch: '',
|
|
115
115
|
isMainWorktree: false,
|
|
116
116
|
hasSession: false,
|
|
@@ -39,6 +39,15 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
39
39
|
sessionManager.on('sessionRestore', handleSessionRestore);
|
|
40
40
|
// Mark session as active (this will trigger the restore event)
|
|
41
41
|
sessionManager.setSessionActive(session.worktreePath, true);
|
|
42
|
+
// Immediately resize the PTY and terminal to current dimensions
|
|
43
|
+
// This fixes rendering issues when terminal width changed while in menu
|
|
44
|
+
// https://github.com/kbwo/ccmanager/issues/2
|
|
45
|
+
const currentCols = process.stdout.columns || 80;
|
|
46
|
+
const currentRows = process.stdout.rows || 24;
|
|
47
|
+
session.process.resize(currentCols, currentRows);
|
|
48
|
+
if (session.terminal) {
|
|
49
|
+
session.terminal.resize(currentCols, currentRows);
|
|
50
|
+
}
|
|
42
51
|
// Listen for session data events
|
|
43
52
|
const handleSessionData = (activeSession, data) => {
|
|
44
53
|
// Only handle data for our session
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ConfigurationData, StatusHookConfig, ShortcutConfig } from '../types/index.js';
|
|
2
|
+
export declare class ConfigurationManager {
|
|
3
|
+
private configPath;
|
|
4
|
+
private legacyShortcutsPath;
|
|
5
|
+
private config;
|
|
6
|
+
constructor();
|
|
7
|
+
private loadConfig;
|
|
8
|
+
private migrateLegacyShortcuts;
|
|
9
|
+
private saveConfig;
|
|
10
|
+
getShortcuts(): ShortcutConfig;
|
|
11
|
+
setShortcuts(shortcuts: ShortcutConfig): void;
|
|
12
|
+
getStatusHooks(): StatusHookConfig;
|
|
13
|
+
setStatusHooks(hooks: StatusHookConfig): void;
|
|
14
|
+
getConfiguration(): ConfigurationData;
|
|
15
|
+
setConfiguration(config: ConfigurationData): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const configurationManager: ConfigurationManager;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { DEFAULT_SHORTCUTS, } from '../types/index.js';
|
|
5
|
+
export class ConfigurationManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
Object.defineProperty(this, "configPath", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
value: void 0
|
|
12
|
+
});
|
|
13
|
+
Object.defineProperty(this, "legacyShortcutsPath", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
value: void 0
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(this, "config", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: {}
|
|
24
|
+
});
|
|
25
|
+
// Determine config directory based on platform
|
|
26
|
+
const homeDir = homedir();
|
|
27
|
+
const configDir = process.platform === 'win32'
|
|
28
|
+
? join(process.env['APPDATA'] || join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
29
|
+
: join(homeDir, '.config', 'ccmanager');
|
|
30
|
+
// Ensure config directory exists
|
|
31
|
+
if (!existsSync(configDir)) {
|
|
32
|
+
mkdirSync(configDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
this.configPath = join(configDir, 'config.json');
|
|
35
|
+
this.legacyShortcutsPath = join(configDir, 'shortcuts.json');
|
|
36
|
+
this.loadConfig();
|
|
37
|
+
}
|
|
38
|
+
loadConfig() {
|
|
39
|
+
// Try to load the new config file
|
|
40
|
+
if (existsSync(this.configPath)) {
|
|
41
|
+
try {
|
|
42
|
+
const configData = readFileSync(this.configPath, 'utf-8');
|
|
43
|
+
this.config = JSON.parse(configData);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error('Failed to load configuration:', error);
|
|
47
|
+
this.config = {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// If new config doesn't exist, check for legacy shortcuts.json
|
|
52
|
+
this.migrateLegacyShortcuts();
|
|
53
|
+
}
|
|
54
|
+
// Check if shortcuts need to be loaded from legacy file
|
|
55
|
+
// This handles the case where config.json exists but doesn't have shortcuts
|
|
56
|
+
if (!this.config.shortcuts && existsSync(this.legacyShortcutsPath)) {
|
|
57
|
+
this.migrateLegacyShortcuts();
|
|
58
|
+
}
|
|
59
|
+
// Ensure default values
|
|
60
|
+
if (!this.config.shortcuts) {
|
|
61
|
+
this.config.shortcuts = DEFAULT_SHORTCUTS;
|
|
62
|
+
}
|
|
63
|
+
if (!this.config.statusHooks) {
|
|
64
|
+
this.config.statusHooks = {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
migrateLegacyShortcuts() {
|
|
68
|
+
if (existsSync(this.legacyShortcutsPath)) {
|
|
69
|
+
try {
|
|
70
|
+
const shortcutsData = readFileSync(this.legacyShortcutsPath, 'utf-8');
|
|
71
|
+
const shortcuts = JSON.parse(shortcutsData);
|
|
72
|
+
// Validate that it's a valid shortcuts config
|
|
73
|
+
if (shortcuts && typeof shortcuts === 'object') {
|
|
74
|
+
this.config.shortcuts = shortcuts;
|
|
75
|
+
// Save to new config format
|
|
76
|
+
this.saveConfig();
|
|
77
|
+
console.log('Migrated shortcuts from legacy shortcuts.json to config.json');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error('Failed to migrate legacy shortcuts:', error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
saveConfig() {
|
|
86
|
+
try {
|
|
87
|
+
writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error('Failed to save configuration:', error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
getShortcuts() {
|
|
94
|
+
return this.config.shortcuts || DEFAULT_SHORTCUTS;
|
|
95
|
+
}
|
|
96
|
+
setShortcuts(shortcuts) {
|
|
97
|
+
this.config.shortcuts = shortcuts;
|
|
98
|
+
this.saveConfig();
|
|
99
|
+
}
|
|
100
|
+
getStatusHooks() {
|
|
101
|
+
return this.config.statusHooks || {};
|
|
102
|
+
}
|
|
103
|
+
setStatusHooks(hooks) {
|
|
104
|
+
this.config.statusHooks = hooks;
|
|
105
|
+
this.saveConfig();
|
|
106
|
+
}
|
|
107
|
+
getConfiguration() {
|
|
108
|
+
return this.config;
|
|
109
|
+
}
|
|
110
|
+
setConfiguration(config) {
|
|
111
|
+
this.config = config;
|
|
112
|
+
this.saveConfig();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export const configurationManager = new ConfigurationManager();
|
|
@@ -15,6 +15,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
15
15
|
setSessionActive(worktreePath: string, active: boolean): void;
|
|
16
16
|
destroySession(worktreePath: string): void;
|
|
17
17
|
getAllSessions(): Session[];
|
|
18
|
+
private executeStatusHook;
|
|
18
19
|
destroy(): void;
|
|
19
20
|
}
|
|
20
21
|
export {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node-pty';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
import { configurationManager } from './configurationManager.js';
|
|
6
|
+
import { WorktreeService } from './worktreeService.js';
|
|
4
7
|
const { Terminal } = pkg;
|
|
5
8
|
export class SessionManager extends EventEmitter {
|
|
6
9
|
stripAnsi(str) {
|
|
@@ -131,6 +134,7 @@ export class SessionManager extends EventEmitter {
|
|
|
131
134
|
const newState = this.detectTerminalState(session.terminal);
|
|
132
135
|
if (newState !== oldState) {
|
|
133
136
|
session.state = newState;
|
|
137
|
+
this.executeStatusHook(oldState, newState, session);
|
|
134
138
|
this.emit('sessionStateChanged', session);
|
|
135
139
|
}
|
|
136
140
|
}, 100); // Check every 100ms
|
|
@@ -187,6 +191,36 @@ export class SessionManager extends EventEmitter {
|
|
|
187
191
|
getAllSessions() {
|
|
188
192
|
return Array.from(this.sessions.values());
|
|
189
193
|
}
|
|
194
|
+
executeStatusHook(oldState, newState, session) {
|
|
195
|
+
const statusHooks = configurationManager.getStatusHooks();
|
|
196
|
+
const hook = statusHooks[newState];
|
|
197
|
+
if (hook && hook.enabled && hook.command) {
|
|
198
|
+
// Get branch information
|
|
199
|
+
const worktreeService = new WorktreeService();
|
|
200
|
+
const worktrees = worktreeService.getWorktrees();
|
|
201
|
+
const worktree = worktrees.find(wt => wt.path === session.worktreePath);
|
|
202
|
+
const branch = worktree?.branch || 'unknown';
|
|
203
|
+
// Execute the hook command in the session's worktree directory
|
|
204
|
+
exec(hook.command, {
|
|
205
|
+
cwd: session.worktreePath,
|
|
206
|
+
env: {
|
|
207
|
+
...process.env,
|
|
208
|
+
CCMANAGER_OLD_STATE: oldState,
|
|
209
|
+
CCMANAGER_NEW_STATE: newState,
|
|
210
|
+
CCMANAGER_WORKTREE: session.worktreePath,
|
|
211
|
+
CCMANAGER_WORKTREE_BRANCH: branch,
|
|
212
|
+
CCMANAGER_SESSION_ID: session.id,
|
|
213
|
+
},
|
|
214
|
+
}, (error, _stdout, stderr) => {
|
|
215
|
+
if (error) {
|
|
216
|
+
console.error(`Failed to execute ${newState} hook: ${error.message}`);
|
|
217
|
+
}
|
|
218
|
+
if (stderr) {
|
|
219
|
+
console.error(`Hook stderr: ${stderr}`);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
190
224
|
destroy() {
|
|
191
225
|
// Clean up all sessions
|
|
192
226
|
for (const worktreePath of this.sessions.keys()) {
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { ShortcutKey, ShortcutConfig } from '../types/index.js';
|
|
2
2
|
import { Key } from 'ink';
|
|
3
3
|
export declare class ShortcutManager {
|
|
4
|
-
private shortcuts;
|
|
5
|
-
private configPath;
|
|
6
4
|
private reservedKeys;
|
|
7
5
|
constructor();
|
|
8
|
-
private loadShortcuts;
|
|
9
6
|
private validateShortcut;
|
|
10
7
|
private isReservedKey;
|
|
11
8
|
saveShortcuts(shortcuts: ShortcutConfig): boolean;
|
|
@@ -1,21 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import * as os from 'os';
|
|
1
|
+
import { configurationManager } from './configurationManager.js';
|
|
5
2
|
export class ShortcutManager {
|
|
6
3
|
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
4
|
Object.defineProperty(this, "reservedKeys", {
|
|
20
5
|
enumerable: true,
|
|
21
6
|
configurable: true,
|
|
@@ -27,31 +12,6 @@ export class ShortcutManager {
|
|
|
27
12
|
{ ctrl: true, key: '[' },
|
|
28
13
|
]
|
|
29
14
|
});
|
|
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
15
|
}
|
|
56
16
|
validateShortcut(shortcut) {
|
|
57
17
|
if (!shortcut || typeof shortcut !== 'object') {
|
|
@@ -88,30 +48,21 @@ export class ShortcutManager {
|
|
|
88
48
|
}
|
|
89
49
|
saveShortcuts(shortcuts) {
|
|
90
50
|
// Validate all shortcuts
|
|
51
|
+
const currentShortcuts = configurationManager.getShortcuts();
|
|
91
52
|
const validated = {
|
|
92
53
|
returnToMenu: this.validateShortcut(shortcuts.returnToMenu) ||
|
|
93
|
-
|
|
94
|
-
cancel: this.validateShortcut(shortcuts.cancel) ||
|
|
54
|
+
currentShortcuts.returnToMenu,
|
|
55
|
+
cancel: this.validateShortcut(shortcuts.cancel) || currentShortcuts.cancel,
|
|
95
56
|
};
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
}
|
|
57
|
+
configurationManager.setShortcuts(validated);
|
|
58
|
+
return true;
|
|
109
59
|
}
|
|
110
60
|
getShortcuts() {
|
|
111
|
-
return
|
|
61
|
+
return configurationManager.getShortcuts();
|
|
112
62
|
}
|
|
113
63
|
matchesShortcut(shortcutName, input, key) {
|
|
114
|
-
const
|
|
64
|
+
const shortcuts = configurationManager.getShortcuts();
|
|
65
|
+
const shortcut = shortcuts[shortcutName];
|
|
115
66
|
if (!shortcut)
|
|
116
67
|
return false;
|
|
117
68
|
// Handle escape key specially
|
|
@@ -129,7 +80,8 @@ export class ShortcutManager {
|
|
|
129
80
|
return input.toLowerCase() === shortcut.key.toLowerCase();
|
|
130
81
|
}
|
|
131
82
|
getShortcutDisplay(shortcutName) {
|
|
132
|
-
const
|
|
83
|
+
const shortcuts = configurationManager.getShortcuts();
|
|
84
|
+
const shortcut = shortcuts[shortcutName];
|
|
133
85
|
if (!shortcut)
|
|
134
86
|
return '';
|
|
135
87
|
const parts = [];
|
|
@@ -32,7 +32,12 @@ export class WorktreeService {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
else if (line.startsWith('branch ')) {
|
|
35
|
-
|
|
35
|
+
let branch = line.substring(7);
|
|
36
|
+
// Remove refs/heads/ prefix if present
|
|
37
|
+
if (branch.startsWith('refs/heads/')) {
|
|
38
|
+
branch = branch.substring(11);
|
|
39
|
+
}
|
|
40
|
+
currentWorktree.branch = branch;
|
|
36
41
|
}
|
|
37
42
|
else if (line === 'bare') {
|
|
38
43
|
currentWorktree.isMainWorktree = true;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -38,3 +38,16 @@ export interface ShortcutConfig {
|
|
|
38
38
|
cancel: ShortcutKey;
|
|
39
39
|
}
|
|
40
40
|
export declare const DEFAULT_SHORTCUTS: ShortcutConfig;
|
|
41
|
+
export interface StatusHook {
|
|
42
|
+
command: string;
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface StatusHookConfig {
|
|
46
|
+
idle?: StatusHook;
|
|
47
|
+
busy?: StatusHook;
|
|
48
|
+
waiting_input?: StatusHook;
|
|
49
|
+
}
|
|
50
|
+
export interface ConfigurationData {
|
|
51
|
+
shortcuts?: ShortcutConfig;
|
|
52
|
+
statusHooks?: StatusHookConfig;
|
|
53
|
+
}
|