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 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
@@ -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 ConfigureShortcuts from './ConfigureShortcuts.js';
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 configure shortcuts option
66
- if (worktree.path === 'CONFIGURE_SHORTCUTS') {
67
- setView('configure-shortcuts');
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 === 'configure-shortcuts') {
224
- return React.createElement(ConfigureShortcuts, { onComplete: handleReturnToMenu });
223
+ if (view === 'configuration') {
224
+ return React.createElement(Configuration, { onComplete: handleReturnToMenu });
225
225
  }
226
226
  return null;
227
227
  };
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ interface ConfigurationProps {
3
+ onComplete: () => void;
4
+ }
5
+ declare const Configuration: React.FC<ConfigurationProps>;
6
+ export default Configuration;
@@ -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,6 @@
1
+ import React from 'react';
2
+ interface ConfigureHooksProps {
3
+ onComplete: () => void;
4
+ }
5
+ declare const ConfigureHooks: React.FC<ConfigureHooksProps>;
6
+ export default ConfigureHooks;
@@ -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;
@@ -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} Configure Shortcuts`,
71
- value: 'configure-shortcuts',
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 === 'configure-shortcuts') {
110
+ else if (item.value === 'configuration') {
111
111
  // Handle in parent component - use special marker
112
112
  onSelectWorktree({
113
- path: 'CONFIGURE_SHORTCUTS',
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
- // Replay all buffered output, but skip the initial clear if present
16
- for (let i = 0; i < restoredSession.outputHistory.length; i++) {
17
- const buffer = restoredSession.outputHistory[i];
18
- if (!buffer)
19
- continue;
20
- const str = buffer.toString('utf8');
21
- // Skip clear screen sequences at the beginning
22
- if (i === 0 && (str.includes('\x1B[2J') || str.includes('\x1B[H'))) {
23
- // Skip this buffer or remove the clear sequence
24
- const cleaned = str
25
- .replace(/\x1B\[2J/g, '')
26
- .replace(/\x1B\[H/g, '');
27
- if (cleaned.length > 0) {
28
- stdout.write(Buffer.from(cleaned, 'utf8'));
29
- }
30
- }
31
- else {
32
- stdout.write(buffer);
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,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 {};