ccmanager 3.12.4 → 3.12.6

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.
@@ -105,7 +105,7 @@ const ConfigureStatusHooks = ({ onComplete, }) => {
105
105
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "green", children: "\u2713 Configuration saved successfully!" }) }));
106
106
  }
107
107
  if (view === 'edit') {
108
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
108
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_DIR, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
109
109
  }
110
110
  const scopeLabel = scope === 'project' ? 'Project' : 'Global';
111
111
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Status Hooks (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "\uD83D\uDCCB Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Set commands to run when session status changes:" }) }), _jsx(SelectInput, { items: getMenuItems(), onSelect: handleMenuSelect, isFocused: true, limit: 10 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to go back" }) })] }));
@@ -1,3 +1,3 @@
1
- export declare const STATE_PERSISTENCE_DURATION_MS = 200;
1
+ export declare const STATE_PERSISTENCE_DURATION_MS = 1000;
2
2
  export declare const STATE_CHECK_INTERVAL_MS = 100;
3
- export declare const STATE_MINIMUM_DURATION_MS = 500;
3
+ export declare const STATE_MINIMUM_DURATION_MS = 1000;
@@ -1,6 +1,9 @@
1
- // Duration in milliseconds that a detected state must persist before being confirmed
2
- export const STATE_PERSISTENCE_DURATION_MS = 200;
1
+ // Duration in milliseconds that a detected state must persist before being confirmed.
2
+ // A higher value prevents transient flicker (e.g., brief "idle" during terminal re-renders)
3
+ // at the cost of slightly slower state transitions.
4
+ export const STATE_PERSISTENCE_DURATION_MS = 1000;
3
5
  // Check interval for state detection in milliseconds
4
6
  export const STATE_CHECK_INTERVAL_MS = 100;
5
- // Minimum duration in current state before allowing transition to a new state
6
- export const STATE_MINIMUM_DURATION_MS = 500;
7
+ // Minimum duration in current state before allowing transition to a new state.
8
+ // Prevents rapid back-and-forth flicker (e.g., busy → idle → busy).
9
+ export const STATE_MINIMUM_DURATION_MS = 1000;
@@ -194,18 +194,12 @@ export class SessionManager extends EventEmitter {
194
194
  return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
195
195
  }
196
196
  createTerminal() {
197
- const terminal = new Terminal({
197
+ return new Terminal({
198
198
  cols: process.stdout.columns || 80,
199
199
  rows: process.stdout.rows || 24,
200
200
  allowProposedApi: true,
201
201
  logLevel: 'off',
202
202
  });
203
- // Disable auto-wrap to match the real terminal setting (Session.tsx sends
204
- // \x1b[?7l to stdout). Without this, long lines wrap in xterm-headless but
205
- // are clipped on the real terminal, causing Ink's cursor-up re-render to
206
- // leave ghost content (old spinners, "esc to interrupt", etc.) in the buffer.
207
- terminal.write('\x1b[?7l');
208
- return terminal;
209
203
  }
210
204
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
211
205
  const id = this.createSessionId();
@@ -210,9 +210,8 @@ describe('SessionManager - State Persistence', () => {
210
210
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
211
211
  // Simulate output that would trigger idle state
212
212
  eventEmitter.emit('data', 'Some output without busy indicators');
213
- // Advance time enough for persistence duration but less than minimum duration
214
- // STATE_PERSISTENCE_DURATION_MS (200ms) < STATE_MINIMUM_DURATION_MS (500ms)
215
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS + STATE_CHECK_INTERVAL_MS * 2);
213
+ // Advance time less than persistence duration so transition is not yet confirmed
214
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS - STATE_CHECK_INTERVAL_MS);
216
215
  // State should still be busy because minimum duration hasn't elapsed
217
216
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
218
217
  expect(stateChangeHandler).not.toHaveBeenCalled();
@@ -251,10 +250,10 @@ describe('SessionManager - State Persistence', () => {
251
250
  await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
252
251
  // Pending state should be set to idle
253
252
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
254
- // Advance past persistence duration (200ms) but NOT past minimum duration (500ms)
255
- // Since stateConfirmedAt was updated at ~2000ms, and now is ~2200ms,
256
- // timeInCurrentState = ~200ms which is < 500ms
257
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
253
+ // Advance past half the persistence duration but not fully
254
+ // Since stateConfirmedAt was updated at ~2000ms, this is not enough
255
+ // for the pending state to be confirmed
256
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS / 2);
258
257
  // State should still be busy because minimum duration since last busy detection hasn't elapsed
259
258
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
260
259
  expect(stateChangeHandler).not.toHaveBeenCalled();
@@ -12,6 +12,13 @@ export declare class ClaudeStateDetector extends BaseStateDetector {
12
12
  * If no prompt box is found, returns all content as fallback.
13
13
  */
14
14
  private getContentAbovePromptBox;
15
+ /**
16
+ * Claude Code frequently redraws the lower pane using cursor-addressed updates.
17
+ * xterm's buffer can retain transient fragments from those redraws outside the
18
+ * latest visible content block, so busy detection should only inspect the most
19
+ * recent contiguous block directly above the prompt box.
20
+ */
21
+ private getRecentContentAbovePromptBox;
15
22
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
16
23
  detectBackgroundTask(terminal: Terminal): number;
17
24
  detectTeamMembers(terminal: Terminal): number;
@@ -3,6 +3,7 @@ import { BaseStateDetector } from './base.js';
3
3
  const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆';
4
4
  // Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
5
5
  const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
6
+ const BUSY_LOOKBACK_LINES = 5;
6
7
  export class ClaudeStateDetector extends BaseStateDetector {
7
8
  /**
8
9
  * Extract content above the prompt box.
@@ -29,6 +30,37 @@ export class ClaudeStateDetector extends BaseStateDetector {
29
30
  // No prompt box found, return all content
30
31
  return lines.join('\n');
31
32
  }
33
+ /**
34
+ * Claude Code frequently redraws the lower pane using cursor-addressed updates.
35
+ * xterm's buffer can retain transient fragments from those redraws outside the
36
+ * latest visible content block, so busy detection should only inspect the most
37
+ * recent contiguous block directly above the prompt box.
38
+ */
39
+ getRecentContentAbovePromptBox(terminal, maxLines) {
40
+ const lines = this.getContentAbovePromptBox(terminal, maxLines).split('\n');
41
+ while (lines.length > 0) {
42
+ const trimmed = lines[lines.length - 1].trim();
43
+ if (trimmed === '' || trimmed === '❯' || /^[-─\s]+$/.test(trimmed)) {
44
+ lines.pop();
45
+ continue;
46
+ }
47
+ break;
48
+ }
49
+ if (lines.length === 0) {
50
+ return '';
51
+ }
52
+ let start = lines.length - 1;
53
+ while (start >= 0) {
54
+ const trimmed = lines[start].trim();
55
+ if (trimmed === '' || /^[-─\s]+$/.test(trimmed)) {
56
+ start++;
57
+ break;
58
+ }
59
+ start--;
60
+ }
61
+ const recentBlock = lines.slice(Math.max(start, 0));
62
+ return recentBlock.slice(-BUSY_LOOKBACK_LINES).join('\n');
63
+ }
32
64
  detectState(terminal, currentState) {
33
65
  // Check for search prompt (⌕ Search…) within 200 lines - always idle
34
66
  const extendedContent = this.getTerminalContent(terminal, 200);
@@ -56,7 +88,7 @@ export class ClaudeStateDetector extends BaseStateDetector {
56
88
  return 'waiting_input';
57
89
  }
58
90
  // Content above the prompt box only for busy detection
59
- const abovePromptBox = this.getContentAbovePromptBox(terminal, 30);
91
+ const abovePromptBox = this.getRecentContentAbovePromptBox(terminal, 30);
60
92
  const aboveLowerContent = abovePromptBox.toLowerCase();
61
93
  // Check for busy state
62
94
  if (aboveLowerContent.includes('esc to interrupt') ||
@@ -423,6 +423,37 @@ describe('ClaudeStateDetector', () => {
423
423
  // Assert - Should be idle because search prompt takes precedence
424
424
  expect(state).toBe('idle');
425
425
  });
426
+ it('should ignore stale spinner output outside the latest block above the prompt box', () => {
427
+ terminal = createMockTerminal([
428
+ '✻ Seasoning… (44s · ↓ 247 tokens)',
429
+ ' ⎿ Tip: Use /btw to ask a quick side question',
430
+ '',
431
+ '⏺ 全て通過。',
432
+ '',
433
+ ' - lint: pass (0 errors)',
434
+ ' - typecheck: pass',
435
+ ' - tests: 56 files, 775 passed, 5 skipped',
436
+ '──────────────────────────────',
437
+ '❯',
438
+ '──────────────────────────────',
439
+ ]);
440
+ const state = detector.detectState(terminal, 'busy');
441
+ expect(state).toBe('idle');
442
+ });
443
+ it('should ignore stale interrupt text outside the latest block above the prompt box', () => {
444
+ terminal = createMockTerminal([
445
+ 'Press esc to interrupt',
446
+ 'Working...',
447
+ '',
448
+ 'Command completed successfully',
449
+ 'Ready for next command',
450
+ '──────────────────────────────',
451
+ '❯',
452
+ '──────────────────────────────',
453
+ ]);
454
+ const state = detector.detectState(terminal, 'busy');
455
+ expect(state).toBe('idle');
456
+ });
426
457
  it('should ignore "esc to interrupt" inside prompt box', () => {
427
458
  // Arrange - "esc to interrupt" is inside the prompt box, not above it
428
459
  terminal = createMockTerminal([
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
+ import { dirname } from 'path';
2
3
  import { Effect } from 'effect';
3
4
  import { ProcessError } from '../types/errors.js';
4
5
  import { WorktreeService } from '../services/worktreeService.js';
@@ -148,6 +149,7 @@ export function executeStatusHook(oldState, newState, session) {
148
149
  // Build environment for status hook
149
150
  const environment = {
150
151
  CCMANAGER_WORKTREE_PATH: session.worktreePath,
152
+ CCMANAGER_WORKTREE_DIR: dirname(session.worktreePath),
151
153
  CCMANAGER_WORKTREE_BRANCH: branch,
152
154
  CCMANAGER_GIT_ROOT: session.worktreePath, // For status hooks, we use worktree path as cwd
153
155
  CCMANAGER_OLD_STATE: oldState,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.12.4",
3
+ "version": "3.12.6",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "3.12.4",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.12.4",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.12.4",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.12.4",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.12.4"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.12.6",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.12.6",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.12.6",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.12.6",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.12.6"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",