ccmanager 0.1.0 → 0.1.2
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 +19 -18
- package/dist/services/configurationManager.d.ts +17 -0
- package/dist/services/configurationManager.js +115 -0
- package/dist/services/sessionManager.colorRestore.test.d.ts +1 -0
- package/dist/services/sessionManager.colorRestore.test.js +142 -0
- package/dist/services/sessionManager.d.ts +1 -0
- package/dist/services/sessionManager.integration.test.d.ts +1 -0
- package/dist/services/sessionManager.integration.test.js +178 -0
- package/dist/services/sessionManager.js +41 -16
- 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 +16 -1
- package/dist/utils/terminalSerializer.d.ts +119 -0
- package/dist/utils/terminalSerializer.js +376 -0
- package/dist/utils/terminalSerializer.test.d.ts +1 -0
- package/dist/utils/terminalSerializer.test.js +137 -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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { useStdout } from 'ink';
|
|
3
3
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
4
|
+
import { TerminalSerializer } from '../utils/terminalSerializer.js';
|
|
4
5
|
const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
5
6
|
const { stdout } = useStdout();
|
|
6
7
|
const [isExiting, setIsExiting] = useState(false);
|
|
@@ -12,24 +13,24 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
12
13
|
// Handle session restoration
|
|
13
14
|
const handleSessionRestore = (restoredSession) => {
|
|
14
15
|
if (restoredSession.id === session.id) {
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
stdout.write(
|
|
16
|
+
// Instead of replaying all history, use the virtual terminal's current buffer
|
|
17
|
+
// This avoids duplicate content issues
|
|
18
|
+
const terminal = restoredSession.terminal;
|
|
19
|
+
if (terminal) {
|
|
20
|
+
// Use the TerminalSerializer to preserve ANSI escape sequences (colors, styles)
|
|
21
|
+
const serializedOutput = TerminalSerializer.serialize(terminal, {
|
|
22
|
+
trimRight: true,
|
|
23
|
+
includeEmptyLines: true,
|
|
24
|
+
});
|
|
25
|
+
// Write the serialized terminal state with preserved formatting
|
|
26
|
+
if (serializedOutput) {
|
|
27
|
+
stdout.write(serializedOutput);
|
|
28
|
+
// Position cursor at the correct location
|
|
29
|
+
const buffer = terminal.buffer.active;
|
|
30
|
+
const cursorY = buffer.cursorY;
|
|
31
|
+
const cursorX = buffer.cursorX;
|
|
32
|
+
// Move cursor to the saved position
|
|
33
|
+
stdout.write(`\x1B[${cursorY + 1};${cursorX + 1}H`);
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
}
|
|
@@ -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();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { SessionManager } from './sessionManager.js';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
// Create mock pty process
|
|
5
|
+
const createMockPtyProcess = () => {
|
|
6
|
+
const handlers = {
|
|
7
|
+
data: [],
|
|
8
|
+
exit: [],
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
write: vi.fn(),
|
|
12
|
+
resize: vi.fn(),
|
|
13
|
+
onData: vi.fn((handler) => {
|
|
14
|
+
handlers.data.push(handler);
|
|
15
|
+
}),
|
|
16
|
+
onExit: vi.fn((handler) => {
|
|
17
|
+
handlers.exit.push(handler);
|
|
18
|
+
}),
|
|
19
|
+
kill: vi.fn(),
|
|
20
|
+
_emit: (event, ...args) => {
|
|
21
|
+
if (event === 'data' && handlers.data.length > 0) {
|
|
22
|
+
handlers.data.forEach(h => h(args[0]));
|
|
23
|
+
}
|
|
24
|
+
else if (event === 'exit' && handlers.exit.length > 0) {
|
|
25
|
+
handlers.exit.forEach(h => h(args[0]));
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
// Mock node-pty
|
|
31
|
+
vi.mock('node-pty', () => ({
|
|
32
|
+
spawn: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
// Don't mock @xterm/headless - let it use the real implementation
|
|
35
|
+
// since we need actual terminal functionality for color testing
|
|
36
|
+
describe('SessionManager - Color Restoration', () => {
|
|
37
|
+
let sessionManager;
|
|
38
|
+
const mockWorktreePath = '/test/worktree';
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
sessionManager = new SessionManager();
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
it('should preserve ANSI colors when switching between sessions', async () => {
|
|
44
|
+
// Create a mock PTY process
|
|
45
|
+
const mockProcess = createMockPtyProcess();
|
|
46
|
+
vi.mocked(spawn).mockReturnValue(mockProcess);
|
|
47
|
+
sessionManager.createSession(mockWorktreePath);
|
|
48
|
+
const session = sessionManager.sessions.get(mockWorktreePath);
|
|
49
|
+
expect(session).toBeDefined();
|
|
50
|
+
// Simulate colorful output from Claude Code
|
|
51
|
+
const colorfulData = [
|
|
52
|
+
'\x1b[32m✓\x1b[0m File created successfully\n',
|
|
53
|
+
'\x1b[1;34mRunning tests...\x1b[0m\n',
|
|
54
|
+
'\x1b[38;5;196mError:\x1b[0m Test failed\n',
|
|
55
|
+
'\x1b[38;2;255;165;0mWarning:\x1b[0m Deprecated API\n',
|
|
56
|
+
];
|
|
57
|
+
// Activate session first
|
|
58
|
+
sessionManager.setSessionActive(mockWorktreePath, true);
|
|
59
|
+
// Send colored data to the terminal
|
|
60
|
+
for (const data of colorfulData) {
|
|
61
|
+
mockProcess._emit('data', data);
|
|
62
|
+
// Wait for terminal to process the data
|
|
63
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
64
|
+
}
|
|
65
|
+
// Deactivate session
|
|
66
|
+
sessionManager.setSessionActive(mockWorktreePath, false);
|
|
67
|
+
// Set up listener to capture restore event
|
|
68
|
+
let restoredContent = null;
|
|
69
|
+
sessionManager.on('sessionRestore', restoredSession => {
|
|
70
|
+
// In real usage, the Session component would use TerminalSerializer here
|
|
71
|
+
// For this test, we'll verify the terminal buffer contains the data
|
|
72
|
+
const terminal = restoredSession.terminal;
|
|
73
|
+
if (terminal) {
|
|
74
|
+
// Access the terminal buffer to verify colors are preserved
|
|
75
|
+
const buffer = terminal.buffer.active;
|
|
76
|
+
restoredContent = '';
|
|
77
|
+
// Simple check: verify buffer has content
|
|
78
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
79
|
+
const line = buffer.getLine(i);
|
|
80
|
+
if (line) {
|
|
81
|
+
// Check if line has colored cells
|
|
82
|
+
for (let x = 0; x < terminal.cols; x++) {
|
|
83
|
+
const cell = line.getCell(x);
|
|
84
|
+
if (cell && cell.getChars()) {
|
|
85
|
+
const fgColorMode = cell.getFgColorMode();
|
|
86
|
+
const bgColorMode = cell.getBgColorMode();
|
|
87
|
+
// If any cell has non-default color, we know colors are preserved
|
|
88
|
+
if (fgColorMode !== 0 || bgColorMode !== 0) {
|
|
89
|
+
restoredContent = 'has-colors';
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// Reactivate session (simulating switching back)
|
|
99
|
+
sessionManager.setSessionActive(mockWorktreePath, true);
|
|
100
|
+
// Verify that colors were preserved in the terminal buffer
|
|
101
|
+
expect(restoredContent).toBe('has-colors');
|
|
102
|
+
});
|
|
103
|
+
it('should handle complex color sequences during restoration', async () => {
|
|
104
|
+
// Create a mock PTY process
|
|
105
|
+
const mockProcess = createMockPtyProcess();
|
|
106
|
+
vi.mocked(spawn).mockReturnValue(mockProcess);
|
|
107
|
+
sessionManager.createSession(mockWorktreePath);
|
|
108
|
+
const session = sessionManager.sessions.get(mockWorktreePath);
|
|
109
|
+
// Activate session
|
|
110
|
+
sessionManager.setSessionActive(mockWorktreePath, true);
|
|
111
|
+
// Send a complex sequence with cursor movements and color changes
|
|
112
|
+
const complexSequence = [
|
|
113
|
+
'Line 1: Normal text\n',
|
|
114
|
+
'\x1b[32mLine 2: Green text\x1b[0m\n',
|
|
115
|
+
'\x1b[1A\x1b[K\x1b[31mLine 2: Now red text\x1b[0m\n', // Move up, clear line, write red
|
|
116
|
+
'\x1b[1;33mLine 3: Bold yellow\x1b[0m\n',
|
|
117
|
+
'\x1b[48;5;17m\x1b[38;5;231mWhite on dark blue background\x1b[0m\n',
|
|
118
|
+
];
|
|
119
|
+
for (const data of complexSequence) {
|
|
120
|
+
mockProcess._emit('data', data);
|
|
121
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
122
|
+
}
|
|
123
|
+
// Check terminal has processed the sequences correctly
|
|
124
|
+
const terminal = session.terminal;
|
|
125
|
+
expect(terminal).toBeDefined();
|
|
126
|
+
// Verify buffer contains content (actual color verification would require
|
|
127
|
+
// checking individual cells, which is done in terminalSerializer.test.ts)
|
|
128
|
+
const buffer = terminal.buffer.active;
|
|
129
|
+
let hasContent = false;
|
|
130
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
131
|
+
const line = buffer.getLine(i);
|
|
132
|
+
if (line) {
|
|
133
|
+
const text = line.translateToString(true);
|
|
134
|
+
if (text.trim()) {
|
|
135
|
+
hasContent = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
expect(hasContent).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -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 {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|