ccmanager 4.1.7 → 4.1.9

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.
@@ -16,6 +16,7 @@ const ConfigureWorktree = ({ onComplete }) => {
16
16
  const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
17
17
  const [sortByLastSession, setSortByLastSession] = useState(worktreeConfig.sortByLastSession ?? false);
18
18
  const [autoUseDefaultBranch, setAutoUseDefaultBranch] = useState(worktreeConfig.autoUseDefaultBranch ?? false);
19
+ const [includeRemoteBranches, setIncludeRemoteBranches] = useState(worktreeConfig.includeRemoteBranches ?? false);
19
20
  const [editMode, setEditMode] = useState('menu');
20
21
  const [tempPattern, setTempPattern] = useState(pattern);
21
22
  // Show if inheriting from global (for project scope)
@@ -50,6 +51,10 @@ const ConfigureWorktree = ({ onComplete }) => {
50
51
  label: `Auto Use Default Branch: ${autoUseDefaultBranch ? '✅ Enabled' : '❌ Disabled'}`,
51
52
  value: 'toggleAutoUseDefault',
52
53
  },
54
+ {
55
+ label: `Include Remote Branches: ${includeRemoteBranches ? '✅ Enabled' : '❌ Disabled'}`,
56
+ value: 'toggleIncludeRemote',
57
+ },
53
58
  {
54
59
  label: '💾 Save Changes',
55
60
  value: 'save',
@@ -77,6 +82,9 @@ const ConfigureWorktree = ({ onComplete }) => {
77
82
  case 'toggleAutoUseDefault':
78
83
  setAutoUseDefaultBranch(!autoUseDefaultBranch);
79
84
  break;
85
+ case 'toggleIncludeRemote':
86
+ setIncludeRemoteBranches(!includeRemoteBranches);
87
+ break;
80
88
  case 'save':
81
89
  // Save the configuration
82
90
  configEditor.setWorktreeConfig({
@@ -85,6 +93,7 @@ const ConfigureWorktree = ({ onComplete }) => {
85
93
  copySessionData,
86
94
  sortByLastSession,
87
95
  autoUseDefaultBranch,
96
+ includeRemoteBranches,
88
97
  });
89
98
  onComplete();
90
99
  break;
@@ -8,6 +8,7 @@ import { configReader } from '../services/config/configReader.js';
8
8
  import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
9
9
  import { WorktreeService } from '../services/worktreeService.js';
10
10
  import { useSearchMode } from '../hooks/useSearchMode.js';
11
+ import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
11
12
  import SearchableList from './SearchableList.js';
12
13
  import { Effect } from 'effect';
13
14
  import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
@@ -16,7 +17,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
16
17
  const presetsConfig = configReader.getCommandPresets();
17
18
  const isAutoDirectory = worktreeConfig.autoDirectory;
18
19
  const isAutoUseDefaultBranch = worktreeConfig.autoUseDefaultBranch ?? false;
19
- const limit = 10;
20
+ const includeRemoteBranches = worktreeConfig.includeRemoteBranches ?? false;
20
21
  const getInitialStep = () => {
21
22
  if (isAutoDirectory) {
22
23
  return 'base-branch';
@@ -34,20 +35,28 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
34
35
  const [isLoadingBranches, setIsLoadingBranches] = useState(true);
35
36
  const [branchLoadError, setBranchLoadError] = useState(null);
36
37
  const [branches, setBranches] = useState([]);
38
+ const [remoteBranches, setRemoteBranches] = useState([]);
37
39
  const [defaultBranch, setDefaultBranch] = useState('main');
38
40
  useEffect(() => {
39
41
  let cancelled = false;
40
42
  const service = new WorktreeService(projectPath);
41
43
  const loadBranches = async () => {
42
- const workflow = Effect.all([service.getAllBranchesEffect(), service.getDefaultBranchEffect()], { concurrency: 2 });
44
+ const branchesEffect = includeRemoteBranches
45
+ ? service.getBranchesWithRemotesEffect()
46
+ : Effect.map(service.getAllBranchesEffect(), (list) => ({
47
+ local: list,
48
+ remote: [],
49
+ }));
50
+ const workflow = Effect.all([branchesEffect, service.getDefaultBranchEffect()], { concurrency: 2 });
43
51
  const result = await Effect.runPromise(Effect.match(workflow, {
44
52
  onFailure: (error) => ({
45
53
  type: 'error',
46
54
  message: formatError(error),
47
55
  }),
48
- onSuccess: ([branchList, defaultBr]) => ({
56
+ onSuccess: ([branchData, defaultBr]) => ({
49
57
  type: 'success',
50
- branches: branchList,
58
+ local: branchData.local,
59
+ remote: branchData.remote,
51
60
  defaultBranch: defaultBr,
52
61
  }),
53
62
  }));
@@ -57,7 +66,8 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
57
66
  setIsLoadingBranches(false);
58
67
  }
59
68
  else {
60
- setBranches(result.branches);
69
+ setBranches(result.local);
70
+ setRemoteBranches(result.remote);
61
71
  setDefaultBranch(result.defaultBranch);
62
72
  setIsLoadingBranches(false);
63
73
  if (isAutoUseDefaultBranch && result.defaultBranch) {
@@ -76,16 +86,31 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
76
86
  return () => {
77
87
  cancelled = true;
78
88
  };
79
- }, [projectPath, isAutoUseDefaultBranch]);
80
- const allBranchItems = useMemo(() => [
81
- { label: `${defaultBranch} (default)`, value: defaultBranch },
82
- ...branches
83
- .filter(br => br !== defaultBranch)
84
- .map(br => ({ label: br, value: br })),
85
- ], [branches, defaultBranch]);
89
+ }, [projectPath, isAutoUseDefaultBranch, includeRemoteBranches]);
90
+ const allBranchItems = useMemo(() => {
91
+ const defaultRemoteSuffix = `/${defaultBranch}`;
92
+ const defaultRemotes = remoteBranches.filter(br => br.endsWith(defaultRemoteSuffix));
93
+ const otherRemotes = remoteBranches.filter(br => !br.endsWith(defaultRemoteSuffix));
94
+ return [
95
+ { label: `${defaultBranch} (default)`, value: defaultBranch },
96
+ ...defaultRemotes.map(br => ({
97
+ label: `${br} (default remote)`,
98
+ value: br,
99
+ })),
100
+ ...branches
101
+ .filter(br => br !== defaultBranch)
102
+ .map(br => ({ label: br, value: br })),
103
+ ...otherRemotes.map(br => ({ label: br, value: br })),
104
+ ];
105
+ }, [branches, remoteBranches, defaultBranch]);
86
106
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(allBranchItems.length, {
87
107
  isDisabled: step !== 'base-branch',
88
108
  });
109
+ const limit = useDynamicLimit({
110
+ fixedRows: includeRemoteBranches ? 10 : 8,
111
+ isSearchMode,
112
+ hasError: !!branchLoadError,
113
+ });
89
114
  const branchItems = useMemo(() => {
90
115
  if (!searchQuery)
91
116
  return allBranchItems;
@@ -239,7 +264,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
239
264
  const promptMethod = selectedPreset
240
265
  ? getPromptInjectionMethod(selectedPreset)
241
266
  : 'stdin';
242
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
267
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
243
268
  {
244
269
  label: '1. Choose the branch name yourself',
245
270
  value: 'manual',
@@ -18,6 +18,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
18
18
  private autoApprovalDisabledWorktrees;
19
19
  private restoringSessions;
20
20
  private bufferedRestoreData;
21
+ private restoreRefreshTimers;
22
+ private restoreRefreshDeadlines;
21
23
  private spawn;
22
24
  private resolvePreset;
23
25
  detectTerminalState(session: Session): SessionState;
@@ -39,6 +41,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
39
41
  private createTerminal;
40
42
  private shouldResetRestoreScrollback;
41
43
  private getRestoreSnapshot;
44
+ private scheduleRestoreRefresh;
45
+ private armRestoreRefreshTimer;
46
+ private cancelRestoreRefresh;
47
+ private fireRestoreRefresh;
42
48
  private createSessionInternal;
43
49
  /**
44
50
  * Create session with command preset using Effect-based error handling
@@ -21,6 +21,15 @@ const { Terminal } = pkg;
21
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
23
  const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
24
+ // Claude Code's Ink-based renderer sometimes splits a single UI redraw across
25
+ // multiple PTY writes with short time gaps. If we snapshot between chunks, the
26
+ // resulting viewport can miss rows (e.g. empty middle area while the top/bottom
27
+ // chrome already rendered). Re-emit the snapshot after the PTY output has been
28
+ // quiet for this long so late chunks are accounted for.
29
+ const RESTORE_REFRESH_QUIET_MS = 120;
30
+ // Cap on how long we wait for quiet before forcing the refresh, so continuous
31
+ // streaming output (e.g. a long busy turn) still produces an updated snapshot.
32
+ const RESTORE_REFRESH_MAX_WAIT_MS = 400;
24
33
  export class SessionManager extends EventEmitter {
25
34
  sessions;
26
35
  waitingWithBottomBorder = new Map();
@@ -28,6 +37,8 @@ export class SessionManager extends EventEmitter {
28
37
  autoApprovalDisabledWorktrees = new Set();
29
38
  restoringSessions = new Set();
30
39
  bufferedRestoreData = new Map();
40
+ restoreRefreshTimers = new Map();
41
+ restoreRefreshDeadlines = new Map();
31
42
  async spawn(command, args, worktreePath, options = {}) {
32
43
  const spawnOptions = {
33
44
  name: 'xterm-256color',
@@ -205,7 +216,7 @@ export class SessionManager extends EventEmitter {
205
216
  data.includes('\x1b[3J') ||
206
217
  data.includes('\x1bc'));
207
218
  }
208
- getRestoreSnapshot(session) {
219
+ getRestoreSnapshot(session, options = {}) {
209
220
  const activeBuffer = session.terminal.buffer.active;
210
221
  if (activeBuffer.type !== 'normal') {
211
222
  return session.serializer.serialize({
@@ -217,6 +228,22 @@ export class SessionManager extends EventEmitter {
217
228
  if (bufferLength === 0) {
218
229
  return '';
219
230
  }
231
+ // While the session is busy, cursor-addressed status-box redraws can push
232
+ // stale frames into scrollback (e.g. Claude's spinner + token stats line).
233
+ // Those ghost rows render as duplicated status bars when replayed, so
234
+ // restore only the viewport during busy state. Refresh re-emits also
235
+ // bypass scrollback to avoid duplicating history into real-terminal
236
+ // scrollback on top of the initial emit.
237
+ const isBusy = session.stateMutex.getSnapshot().state === 'busy';
238
+ if (options.viewportOnly || isBusy) {
239
+ const snapshot = session.serializer.serialize({
240
+ scrollback: 0,
241
+ excludeAltBuffer: true,
242
+ });
243
+ const cursorRow = normalBuffer.cursorY + 1;
244
+ const cursorCol = normalBuffer.cursorX + 1;
245
+ return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
246
+ }
220
247
  const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
221
248
  const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
222
249
  const rangeEnd = bufferLength - 1;
@@ -231,6 +258,56 @@ export class SessionManager extends EventEmitter {
231
258
  const cursorCol = normalBuffer.cursorX + 1;
232
259
  return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
233
260
  }
261
+ scheduleRestoreRefresh(session) {
262
+ this.restoreRefreshDeadlines.set(session.id, Date.now() + RESTORE_REFRESH_MAX_WAIT_MS);
263
+ this.armRestoreRefreshTimer(session);
264
+ }
265
+ armRestoreRefreshTimer(session) {
266
+ const deadline = this.restoreRefreshDeadlines.get(session.id);
267
+ if (deadline === undefined) {
268
+ return;
269
+ }
270
+ const existing = this.restoreRefreshTimers.get(session.id);
271
+ if (existing !== undefined) {
272
+ clearTimeout(existing);
273
+ this.restoreRefreshTimers.delete(session.id);
274
+ }
275
+ const remaining = deadline - Date.now();
276
+ if (remaining <= 0) {
277
+ this.fireRestoreRefresh(session);
278
+ return;
279
+ }
280
+ const delay = Math.min(RESTORE_REFRESH_QUIET_MS, remaining);
281
+ const timer = setTimeout(() => this.fireRestoreRefresh(session), delay);
282
+ this.restoreRefreshTimers.set(session.id, timer);
283
+ }
284
+ cancelRestoreRefresh(session) {
285
+ const existing = this.restoreRefreshTimers.get(session.id);
286
+ if (existing !== undefined) {
287
+ clearTimeout(existing);
288
+ this.restoreRefreshTimers.delete(session.id);
289
+ }
290
+ this.restoreRefreshDeadlines.delete(session.id);
291
+ }
292
+ fireRestoreRefresh(session) {
293
+ this.restoreRefreshTimers.delete(session.id);
294
+ this.restoreRefreshDeadlines.delete(session.id);
295
+ if (!session.isActive) {
296
+ return;
297
+ }
298
+ const snapshot = this.getRestoreSnapshot(session, { viewportOnly: true });
299
+ if (snapshot.length > 0) {
300
+ // \x1b[2J: clear the viewport before repainting — without this, a
301
+ // refresh snapshot shorter than the already-displayed content leaves
302
+ // a "ghost tail" of pre-refresh rows at the bottom.
303
+ // \x1b[?7h / \x1b[?7l: Session.tsx disables auto-wrap (DECAWM) after
304
+ // the initial restore, but SerializeAddon omits row separators for
305
+ // wrapped lines and relies on DECAWM to advance to the next row
306
+ // (see PR #276). Re-enable auto-wrap just for the snapshot write so
307
+ // wrapped viewport rows don't overlap, then restore the TUI default.
308
+ this.emit('sessionRestore', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
309
+ }
310
+ }
234
311
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
235
312
  const existingSessions = this.getSessionsForWorktree(worktreePath);
236
313
  const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
@@ -334,6 +411,12 @@ export class SessionManager extends EventEmitter {
334
411
  session.terminal.buffer.normal.baseY;
335
412
  }
336
413
  session.lastActivity = new Date();
414
+ // If a restore-refresh is pending, each incoming chunk resets the
415
+ // quiet timer so the follow-up snapshot only fires after Claude's
416
+ // multi-chunk redraw has settled.
417
+ if (this.restoreRefreshDeadlines.has(session.id)) {
418
+ this.armRestoreRefreshTimer(session);
419
+ }
337
420
  // Only emit data events when session is active
338
421
  if (session.isActive) {
339
422
  if (this.restoringSessions.has(session.id)) {
@@ -493,10 +576,12 @@ export class SessionManager extends EventEmitter {
493
576
  }
494
577
  }
495
578
  }
579
+ this.scheduleRestoreRefresh(session);
496
580
  }
497
581
  else {
498
582
  this.restoringSessions.delete(session.id);
499
583
  this.bufferedRestoreData.delete(session.id);
584
+ this.cancelRestoreRefresh(session);
500
585
  }
501
586
  }
502
587
  }
@@ -571,6 +656,7 @@ export class SessionManager extends EventEmitter {
571
656
  this.waitingWithBottomBorder.delete(sessionId);
572
657
  this.restoringSessions.delete(sessionId);
573
658
  this.bufferedRestoreData.delete(sessionId);
659
+ this.cancelRestoreRefresh(session);
574
660
  this.emit('sessionDestroyed', session);
575
661
  }
576
662
  }
@@ -624,6 +710,7 @@ export class SessionManager extends EventEmitter {
624
710
  }
625
711
  this.sessions.delete(sessionId);
626
712
  this.waitingWithBottomBorder.delete(sessionId);
713
+ this.cancelRestoreRefresh(session);
627
714
  this.emit('sessionDestroyed', session);
628
715
  },
629
716
  catch: (error) => {
@@ -782,6 +782,7 @@ describe('SessionManager', () => {
782
782
  });
783
783
  vi.mocked(spawn).mockReturnValue(mockPty);
784
784
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
785
+ await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
785
786
  const normalBuffer = session.terminal.buffer.normal;
786
787
  normalBuffer.baseY = 260;
787
788
  normalBuffer.length = 300;
@@ -803,6 +804,32 @@ describe('SessionManager', () => {
803
804
  });
804
805
  expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
805
806
  });
807
+ it('should emit a viewport-only restore snapshot while session is busy', async () => {
808
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
809
+ id: '1',
810
+ name: 'Main',
811
+ command: 'claude',
812
+ });
813
+ vi.mocked(spawn).mockReturnValue(mockPty);
814
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
815
+ const normalBuffer = session.terminal.buffer.normal;
816
+ normalBuffer.baseY = 260;
817
+ normalBuffer.length = 300;
818
+ normalBuffer.cursorY = 7;
819
+ normalBuffer.cursorX = 11;
820
+ session.restoreScrollbackBaseLine = 120;
821
+ const serializeMock = vi
822
+ .spyOn(session.serializer, 'serialize')
823
+ .mockReturnValue('\u001b[31mbusy-viewport\u001b[0m');
824
+ const restoreHandler = vi.fn();
825
+ sessionManager.on('sessionRestore', restoreHandler);
826
+ sessionManager.setSessionActive(session.id, true);
827
+ expect(serializeMock).toHaveBeenCalledWith({
828
+ scrollback: 0,
829
+ excludeAltBuffer: true,
830
+ });
831
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mbusy-viewport\u001b[0m\u001b[8;12H');
832
+ });
806
833
  it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
807
834
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
808
835
  id: '1',
@@ -872,6 +899,90 @@ describe('SessionManager', () => {
872
899
  sessionManager.setSessionActive(session.id, true);
873
900
  expect(eventOrder).toEqual(['restore', 'data']);
874
901
  });
902
+ it('should re-emit a viewport-only snapshot after the PTY quiet period', async () => {
903
+ vi.useFakeTimers();
904
+ try {
905
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
906
+ id: '1',
907
+ name: 'Main',
908
+ command: 'claude',
909
+ });
910
+ vi.mocked(spawn).mockReturnValue(mockPty);
911
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
912
+ session.terminal.buffer.normal.length = 1;
913
+ const serializeMock = vi
914
+ .spyOn(session.serializer, 'serialize')
915
+ .mockReturnValue('snap');
916
+ const restoreHandler = vi.fn();
917
+ sessionManager.on('sessionRestore', restoreHandler);
918
+ sessionManager.setSessionActive(session.id, true);
919
+ expect(restoreHandler).toHaveBeenCalledTimes(1);
920
+ await vi.advanceTimersByTimeAsync(50);
921
+ mockPty.emit('data', 'late-chunk');
922
+ await vi.advanceTimersByTimeAsync(50);
923
+ expect(restoreHandler).toHaveBeenCalledTimes(1);
924
+ await vi.advanceTimersByTimeAsync(120);
925
+ expect(restoreHandler).toHaveBeenCalledTimes(2);
926
+ expect(restoreHandler.mock.calls[1]?.[1]).toMatch(/^\u001b\[\?7h\u001b\[2J\u001b\[Hsnap.*\u001b\[\?7l$/);
927
+ expect(serializeMock).toHaveBeenLastCalledWith({
928
+ scrollback: 0,
929
+ excludeAltBuffer: true,
930
+ });
931
+ }
932
+ finally {
933
+ vi.useRealTimers();
934
+ }
935
+ });
936
+ it('should force a refresh at the max-wait deadline even while data keeps streaming', async () => {
937
+ vi.useFakeTimers();
938
+ try {
939
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
940
+ id: '1',
941
+ name: 'Main',
942
+ command: 'claude',
943
+ });
944
+ vi.mocked(spawn).mockReturnValue(mockPty);
945
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
946
+ session.terminal.buffer.normal.length = 1;
947
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
948
+ const restoreHandler = vi.fn();
949
+ sessionManager.on('sessionRestore', restoreHandler);
950
+ sessionManager.setSessionActive(session.id, true);
951
+ expect(restoreHandler).toHaveBeenCalledTimes(1);
952
+ for (let elapsed = 0; elapsed < 400; elapsed += 50) {
953
+ await vi.advanceTimersByTimeAsync(50);
954
+ mockPty.emit('data', 'chunk');
955
+ }
956
+ expect(restoreHandler).toHaveBeenCalledTimes(2);
957
+ }
958
+ finally {
959
+ vi.useRealTimers();
960
+ }
961
+ });
962
+ it('should cancel a scheduled refresh when the session is deactivated', async () => {
963
+ vi.useFakeTimers();
964
+ try {
965
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
966
+ id: '1',
967
+ name: 'Main',
968
+ command: 'claude',
969
+ });
970
+ vi.mocked(spawn).mockReturnValue(mockPty);
971
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
972
+ session.terminal.buffer.normal.length = 1;
973
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
974
+ const restoreHandler = vi.fn();
975
+ sessionManager.on('sessionRestore', restoreHandler);
976
+ sessionManager.setSessionActive(session.id, true);
977
+ expect(restoreHandler).toHaveBeenCalledTimes(1);
978
+ sessionManager.setSessionActive(session.id, false);
979
+ await vi.advanceTimersByTimeAsync(500);
980
+ expect(restoreHandler).toHaveBeenCalledTimes(1);
981
+ }
982
+ finally {
983
+ vi.useRealTimers();
984
+ }
985
+ });
875
986
  });
876
987
  describe('static methods', () => {
877
988
  describe('getSessionCounts', () => {
@@ -6,7 +6,6 @@ const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃
6
6
  const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
7
7
  // Session stats above the prompt, e.g. "(9m 21s · ↓ 13.7k tokens)" — requires parens, a digit, and "tokens"
8
8
  const TOKEN_STATS_LINE_PATTERN = /\([^)]*\d[^)]*tokens\s*\)/i;
9
- const BUSY_LOOKBACK_LINES = 5;
10
9
  // Workaround: Claude Code sometimes appears idle in terminal output while
11
10
  // still actively processing (busy). To mitigate false idle transitions,
12
11
  // require terminal output to remain unchanged for this duration before
@@ -88,8 +87,7 @@ export class ClaudeStateDetector extends BaseStateDetector {
88
87
  }
89
88
  start--;
90
89
  }
91
- const recentBlock = lines.slice(Math.max(start, 0));
92
- return recentBlock.slice(-BUSY_LOOKBACK_LINES).join('\n');
90
+ return lines.slice(Math.max(start, 0)).join('\n');
93
91
  }
94
92
  detectState(terminal, currentState) {
95
93
  // Check for search prompt (⌕ Search…) within 200 lines - always idle (debounced)
@@ -446,6 +446,27 @@ describe('ClaudeStateDetector', () => {
446
446
  // Assert - Should be idle because search prompt takes precedence
447
447
  expect(state).toBe('idle');
448
448
  });
449
+ it('should detect busy when spinner + token stats header is followed by a long TodoWrite checklist', () => {
450
+ // Regression: the recent block contains a spinner/token header and
451
+ // many checklist items with no internal blank line. All lines are
452
+ // part of the same contiguous update and should be inspected.
453
+ terminal = createMockTerminal([
454
+ '✽ Add GitHub Actions workflow and commit… (50s · ↓ 794 tokens)',
455
+ ' ⎿ ✔ Create docs/index.config.json',
456
+ ' ✔ Reorganize existing docs into topic directories',
457
+ ' ✔ Add frontmatter to existing docs',
458
+ ' ✔ Create docs/INDEX.md, docs/README.md, templates, workflow',
459
+ ' ✔ Create root AGENTS.md and README.md',
460
+ ' ✔ Write manifest generation Go script',
461
+ ' ✔ Add Makefile tasks and run first generation',
462
+ ' ◼ Add GitHub Actions workflow and commit',
463
+ '──────────────────────────────',
464
+ '❯',
465
+ '──────────────────────────────',
466
+ ]);
467
+ const state = detector.detectState(terminal, 'idle');
468
+ expect(state).toBe('busy');
469
+ });
449
470
  it('should ignore stale spinner output outside the latest block above the prompt box', () => {
450
471
  terminal = createMockTerminal([
451
472
  '✻ Seasoning… (44s · ↓ 247 tokens)',
@@ -201,6 +201,17 @@ export declare class WorktreeService {
201
201
  * @throws {GitError} When git branch command fails (but falls back to empty array)
202
202
  */
203
203
  getAllBranchesEffect(): Effect.Effect<string[], GitError, never>;
204
+ /**
205
+ * Effect-based getBranchesWithRemotes operation
206
+ * Returns local and remote branches separately so callers can distinguish them.
207
+ * Remote branches keep their `<remote>/<branch>` prefix (e.g. `origin/main`).
208
+ *
209
+ * @returns {Effect.Effect<{local: string[]; remote: string[]}, GitError, never>}
210
+ */
211
+ getBranchesWithRemotesEffect(): Effect.Effect<{
212
+ local: string[];
213
+ remote: string[];
214
+ }, GitError, never>;
204
215
  /**
205
216
  * Effect-based getCurrentBranch operation
206
217
  * Returns Effect that may fail with GitError
@@ -499,6 +499,43 @@ export class WorktreeService {
499
499
  return Effect.succeed([]);
500
500
  });
501
501
  }
502
+ /**
503
+ * Effect-based getBranchesWithRemotes operation
504
+ * Returns local and remote branches separately so callers can distinguish them.
505
+ * Remote branches keep their `<remote>/<branch>` prefix (e.g. `origin/main`).
506
+ *
507
+ * @returns {Effect.Effect<{local: string[]; remote: string[]}, GitError, never>}
508
+ */
509
+ getBranchesWithRemotesEffect() {
510
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
511
+ const self = this;
512
+ return Effect.catchAll(Effect.try({
513
+ try: () => {
514
+ const output = execSync("git branch -a --format='%(refname:short)' | grep -v HEAD | sort -u", {
515
+ cwd: self.rootPath,
516
+ encoding: 'utf8',
517
+ shell: '/bin/bash',
518
+ });
519
+ const remotes = self.getAllRemotes();
520
+ const remotePrefixes = remotes.map(r => `${r}/`);
521
+ const local = [];
522
+ const remote = [];
523
+ for (const raw of output.trim().split('\n')) {
524
+ const branch = raw.trim();
525
+ if (!branch)
526
+ continue;
527
+ if (remotePrefixes.some(prefix => branch.startsWith(prefix))) {
528
+ remote.push(branch);
529
+ }
530
+ else {
531
+ local.push(branch);
532
+ }
533
+ }
534
+ return { local, remote };
535
+ },
536
+ catch: (error) => error,
537
+ }), (_error) => Effect.succeed({ local: [], remote: [] }));
538
+ }
502
539
  /**
503
540
  * Effect-based getCurrentBranch operation
504
541
  * Returns Effect that may fail with GitError
@@ -124,6 +124,7 @@ export interface WorktreeConfig {
124
124
  copySessionData?: boolean;
125
125
  sortByLastSession?: boolean;
126
126
  autoUseDefaultBranch?: boolean;
127
+ includeRemoteBranches?: boolean;
127
128
  }
128
129
  export interface MergeConfig {
129
130
  mergeArgs?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.7",
3
+ "version": "4.1.9",
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": "4.1.7",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.7",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.7",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.7",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.7"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.9",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.9",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.9",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.9",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.9"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",