ccmanager 4.1.6 → 4.1.8

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',
@@ -65,9 +65,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
65
65
  stdout.write('\x1B[2J\x1B[H');
66
66
  // Restore the current terminal state from the headless xterm snapshot.
67
67
  // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
68
- // render wrapped lines it omits row separators for wrapped rows, expecting
69
- // characters to naturally overflow to the next line. We therefore keep
70
- // auto-wrap enabled while writing the snapshot and only disable it afterward.
68
+ // render wrapped lines. It omits row separators for wrapped rows and expects
69
+ // characters to naturally overflow to the next line, so auto-wrap must stay
70
+ // enabled while writing the snapshot and only be disabled afterward.
71
71
  const handleSessionRestore = (restoredSession, restoreSnapshot) => {
72
72
  if (restoredSession.id === session.id) {
73
73
  if (restoreSnapshot.length > 0) {
@@ -109,15 +109,12 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
109
109
  /* empty */
110
110
  }
111
111
  // Mark session as active after resizing so the restore snapshot matches
112
- // the current terminal dimensions. setSessionActive synchronously emits
113
- // the 'sessionRestore' event, so the snapshot is written to stdout before
114
- // we proceed.
112
+ // the current terminal dimensions. setSessionActive synchronously emits the
113
+ // restore event, so the snapshot is written to stdout before we proceed.
115
114
  sessionManager.setSessionActive(session.id, true);
116
115
  // Prevent line wrapping from drifting redraws in TUIs that rely on
117
- // cursor-up clears. This MUST come after the restore snapshot write
118
- // because the xterm serialize addon relies on auto-wrap (DECAWM) being
119
- // enabled — it omits row separators for wrapped rows, expecting characters
120
- // to naturally overflow to the next line.
116
+ // cursor-up clears. This must happen after the restore snapshot write,
117
+ // otherwise wrapped restore content can overlap on the same row.
121
118
  stdout.write('\x1b[?7l');
122
119
  // Handle terminal resize
123
120
  const handleResize = () => {
@@ -69,13 +69,25 @@ vi.mock('@xterm/addon-serialize', () => ({
69
69
  vi.mock('@xterm/headless', () => ({
70
70
  default: {
71
71
  Terminal: vi.fn().mockImplementation(function () {
72
+ const normalBuffer = {
73
+ type: 'normal',
74
+ baseY: 0,
75
+ cursorY: 0,
76
+ cursorX: 0,
77
+ length: 0,
78
+ getLine: vi.fn(),
79
+ };
72
80
  return {
73
81
  rows: 24,
74
82
  cols: 80,
75
83
  buffer: {
76
- active: {
77
- type: 'normal',
84
+ active: normalBuffer,
85
+ normal: normalBuffer,
86
+ alternate: {
87
+ type: 'alternate',
78
88
  baseY: 0,
89
+ cursorY: 0,
90
+ cursorX: 0,
79
91
  length: 0,
80
92
  getLine: vi.fn(),
81
93
  },
@@ -16,6 +16,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
16
16
  private waitingWithBottomBorder;
17
17
  private busyTimers;
18
18
  private autoApprovalDisabledWorktrees;
19
+ private restoringSessions;
20
+ private bufferedRestoreData;
19
21
  private spawn;
20
22
  private resolvePreset;
21
23
  detectTerminalState(session: Session): SessionState;
@@ -35,6 +37,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
35
37
  private updateSessionState;
36
38
  constructor();
37
39
  private createTerminal;
40
+ private shouldResetRestoreScrollback;
38
41
  private getRestoreSnapshot;
39
42
  private createSessionInternal;
40
43
  /**
@@ -46,13 +46,25 @@ vi.mock('@xterm/addon-serialize', () => ({
46
46
  vi.mock('@xterm/headless', () => ({
47
47
  default: {
48
48
  Terminal: vi.fn().mockImplementation(function () {
49
+ const normalBuffer = {
50
+ type: 'normal',
51
+ baseY: 0,
52
+ cursorY: 0,
53
+ cursorX: 0,
54
+ length: 0,
55
+ getLine: vi.fn(),
56
+ };
49
57
  return {
50
58
  rows: 24,
51
59
  cols: 80,
52
60
  buffer: {
53
- active: {
54
- type: 'normal',
61
+ active: normalBuffer,
62
+ normal: normalBuffer,
63
+ alternate: {
64
+ type: 'alternate',
55
65
  baseY: 0,
66
+ cursorY: 0,
67
+ cursorX: 0,
56
68
  length: 0,
57
69
  getLine: vi.fn(),
58
70
  },
@@ -20,11 +20,14 @@ import { preparePresetLaunch } from '../utils/presetPrompt.js';
20
20
  const { Terminal } = pkg;
21
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
+ const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
23
24
  export class SessionManager extends EventEmitter {
24
25
  sessions;
25
26
  waitingWithBottomBorder = new Map();
26
27
  busyTimers = new Map();
27
28
  autoApprovalDisabledWorktrees = new Set();
29
+ restoringSessions = new Set();
30
+ bufferedRestoreData = new Map();
28
31
  async spawn(command, args, worktreePath, options = {}) {
29
32
  const spawnOptions = {
30
33
  name: 'xterm-256color',
@@ -197,10 +200,36 @@ export class SessionManager extends EventEmitter {
197
200
  logLevel: 'off',
198
201
  });
199
202
  }
203
+ shouldResetRestoreScrollback(data) {
204
+ return (data.includes('\x1b[2J') ||
205
+ data.includes('\x1b[3J') ||
206
+ data.includes('\x1bc'));
207
+ }
200
208
  getRestoreSnapshot(session) {
201
- return session.serializer.serialize({
202
- scrollback: 0,
209
+ const activeBuffer = session.terminal.buffer.active;
210
+ if (activeBuffer.type !== 'normal') {
211
+ return session.serializer.serialize({
212
+ scrollback: 0,
213
+ });
214
+ }
215
+ const normalBuffer = session.terminal.buffer.normal;
216
+ const bufferLength = normalBuffer.length;
217
+ if (bufferLength === 0) {
218
+ return '';
219
+ }
220
+ const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
221
+ const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
222
+ const rangeEnd = bufferLength - 1;
223
+ const snapshot = session.serializer.serialize({
224
+ range: {
225
+ start: rangeStart,
226
+ end: rangeEnd,
227
+ },
228
+ excludeAltBuffer: true,
203
229
  });
230
+ const cursorRow = normalBuffer.cursorY + 1;
231
+ const cursorCol = normalBuffer.cursorX + 1;
232
+ return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
204
233
  }
205
234
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
206
235
  const existingSessions = this.getSessionsForWorktree(worktreePath);
@@ -224,6 +253,7 @@ export class SessionManager extends EventEmitter {
224
253
  isActive: false,
225
254
  terminal,
226
255
  serializer,
256
+ restoreScrollbackBaseLine: 0,
227
257
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
228
258
  isPrimaryCommand: options.isPrimaryCommand ?? true,
229
259
  presetName: options.presetName,
@@ -299,9 +329,19 @@ export class SessionManager extends EventEmitter {
299
329
  session.process.onData((data) => {
300
330
  // Write data to virtual terminal
301
331
  session.terminal.write(data);
332
+ if (this.shouldResetRestoreScrollback(data)) {
333
+ session.restoreScrollbackBaseLine =
334
+ session.terminal.buffer.normal.baseY;
335
+ }
302
336
  session.lastActivity = new Date();
303
337
  // Only emit data events when session is active
304
338
  if (session.isActive) {
339
+ if (this.restoringSessions.has(session.id)) {
340
+ const bufferedData = this.bufferedRestoreData.get(session.id) ?? [];
341
+ bufferedData.push(data);
342
+ this.bufferedRestoreData.set(session.id, bufferedData);
343
+ return;
344
+ }
305
345
  this.emit('sessionData', session, data);
306
346
  }
307
347
  });
@@ -436,10 +476,27 @@ export class SessionManager extends EventEmitter {
436
476
  session.isActive = active;
437
477
  if (active) {
438
478
  session.lastAccessedAt = Date.now();
439
- const restoreSnapshot = this.getRestoreSnapshot(session);
440
- if (restoreSnapshot.length > 0) {
441
- this.emit('sessionRestore', session, restoreSnapshot);
479
+ this.restoringSessions.add(session.id);
480
+ try {
481
+ const restoreSnapshot = this.getRestoreSnapshot(session);
482
+ if (restoreSnapshot.length > 0) {
483
+ this.emit('sessionRestore', session, restoreSnapshot);
484
+ }
442
485
  }
486
+ finally {
487
+ this.restoringSessions.delete(session.id);
488
+ const bufferedData = this.bufferedRestoreData.get(session.id);
489
+ if (bufferedData && bufferedData.length > 0) {
490
+ this.bufferedRestoreData.delete(session.id);
491
+ for (const chunk of bufferedData) {
492
+ this.emit('sessionData', session, chunk);
493
+ }
494
+ }
495
+ }
496
+ }
497
+ else {
498
+ this.restoringSessions.delete(session.id);
499
+ this.bufferedRestoreData.delete(session.id);
443
500
  }
444
501
  }
445
502
  }
@@ -512,6 +569,8 @@ export class SessionManager extends EventEmitter {
512
569
  }
513
570
  this.sessions.delete(sessionId);
514
571
  this.waitingWithBottomBorder.delete(sessionId);
572
+ this.restoringSessions.delete(sessionId);
573
+ this.bufferedRestoreData.delete(sessionId);
515
574
  this.emit('sessionDestroyed', session);
516
575
  }
517
576
  }
@@ -50,13 +50,27 @@ vi.mock('@xterm/addon-serialize', () => ({
50
50
  vi.mock('@xterm/headless', () => ({
51
51
  default: {
52
52
  Terminal: vi.fn(function () {
53
+ const normalBuffer = {
54
+ type: 'normal',
55
+ baseY: 0,
56
+ cursorY: 0,
57
+ cursorX: 0,
58
+ length: 0,
59
+ getLine: vi.fn(function () {
60
+ return null;
61
+ }),
62
+ };
53
63
  return {
54
64
  rows: 24,
55
65
  cols: 80,
56
66
  buffer: {
57
- active: {
58
- type: 'normal',
67
+ active: normalBuffer,
68
+ normal: normalBuffer,
69
+ alternate: {
70
+ type: 'alternate',
59
71
  baseY: 0,
72
+ cursorY: 0,
73
+ cursorX: 0,
60
74
  length: 0,
61
75
  getLine: vi.fn(function () {
62
76
  return null;
@@ -760,7 +774,7 @@ describe('SessionManager', () => {
760
774
  });
761
775
  });
762
776
  describe('session restore snapshots', () => {
763
- it('should emit serialized terminal output when activating a session', async () => {
777
+ it('should emit a bounded normal-buffer restore snapshot and restore the cursor position', async () => {
764
778
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
765
779
  id: '1',
766
780
  name: 'Main',
@@ -768,14 +782,44 @@ describe('SessionManager', () => {
768
782
  });
769
783
  vi.mocked(spawn).mockReturnValue(mockPty);
770
784
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
785
+ const normalBuffer = session.terminal.buffer.normal;
786
+ normalBuffer.baseY = 260;
787
+ normalBuffer.length = 300;
788
+ normalBuffer.cursorY = 7;
789
+ normalBuffer.cursorX = 11;
790
+ session.restoreScrollbackBaseLine = 120;
771
791
  const serializeMock = vi
772
792
  .spyOn(session.serializer, 'serialize')
773
793
  .mockReturnValue('\u001b[31mrestored\u001b[0m');
774
794
  const restoreHandler = vi.fn();
775
795
  sessionManager.on('sessionRestore', restoreHandler);
776
796
  sessionManager.setSessionActive(session.id, true);
797
+ expect(serializeMock).toHaveBeenCalledWith({
798
+ range: {
799
+ start: 120,
800
+ end: 299,
801
+ },
802
+ excludeAltBuffer: true,
803
+ });
804
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
805
+ });
806
+ it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
807
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
808
+ id: '1',
809
+ name: 'Main',
810
+ command: 'claude',
811
+ });
812
+ vi.mocked(spawn).mockReturnValue(mockPty);
813
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
814
+ session.terminal.buffer.active = session.terminal.buffer.alternate;
815
+ const serializeMock = vi
816
+ .spyOn(session.serializer, 'serialize')
817
+ .mockReturnValue('\u001b[31malt\u001b[0m');
818
+ const restoreHandler = vi.fn();
819
+ sessionManager.on('sessionRestore', restoreHandler);
820
+ sessionManager.setSessionActive(session.id, true);
777
821
  expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
778
- expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m');
822
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31malt\u001b[0m');
779
823
  });
780
824
  it('should skip restore event when serialized output is empty', async () => {
781
825
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -791,6 +835,43 @@ describe('SessionManager', () => {
791
835
  sessionManager.setSessionActive(session.id, true);
792
836
  expect(restoreHandler).not.toHaveBeenCalled();
793
837
  });
838
+ it('should reset restore scrollback baseline after a clear-screen sequence', async () => {
839
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
840
+ id: '1',
841
+ name: 'Main',
842
+ command: 'claude',
843
+ });
844
+ vi.mocked(spawn).mockReturnValue(mockPty);
845
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
846
+ session.terminal.buffer.normal.baseY = 17;
847
+ mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
848
+ expect(session.restoreScrollbackBaseLine).toBe(17);
849
+ });
850
+ it('should flush live session data after the restore snapshot completes', async () => {
851
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
852
+ id: '1',
853
+ name: 'Main',
854
+ command: 'claude',
855
+ });
856
+ vi.mocked(spawn).mockReturnValue(mockPty);
857
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
858
+ session.terminal.buffer.normal.length = 1;
859
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('restored');
860
+ const eventOrder = [];
861
+ sessionManager.on('sessionRestore', restoredSession => {
862
+ if (restoredSession.id === session.id) {
863
+ eventOrder.push('restore');
864
+ mockPty.emit('data', 'live-output');
865
+ }
866
+ });
867
+ sessionManager.on('sessionData', activeSession => {
868
+ if (activeSession.id === session.id) {
869
+ eventOrder.push('data');
870
+ }
871
+ });
872
+ sessionManager.setSessionActive(session.id, true);
873
+ expect(eventOrder).toEqual(['restore', 'data']);
874
+ });
794
875
  });
795
876
  describe('static methods', () => {
796
877
  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
@@ -30,6 +30,7 @@ export interface Session {
30
30
  isActive: boolean;
31
31
  terminal: Terminal;
32
32
  serializer: SerializeAddon;
33
+ restoreScrollbackBaseLine: number;
33
34
  stateCheckInterval: NodeJS.Timeout | undefined;
34
35
  isPrimaryCommand: boolean;
35
36
  presetName: string | undefined;
@@ -123,6 +124,7 @@ export interface WorktreeConfig {
123
124
  copySessionData?: boolean;
124
125
  sortByLastSession?: boolean;
125
126
  autoUseDefaultBranch?: boolean;
127
+ includeRemoteBranches?: boolean;
126
128
  }
127
129
  export interface MergeConfig {
128
130
  mergeArgs?: string[];
@@ -383,6 +383,7 @@ describe('hookExecutor Integration Tests', () => {
383
383
  process: {},
384
384
  terminal: {},
385
385
  serializer: {},
386
+ restoreScrollbackBaseLine: 0,
386
387
  output: [],
387
388
  stateCheckInterval: undefined,
388
389
  isPrimaryCommand: true,
@@ -442,6 +443,7 @@ describe('hookExecutor Integration Tests', () => {
442
443
  process: {},
443
444
  terminal: {},
444
445
  serializer: {},
446
+ restoreScrollbackBaseLine: 0,
445
447
  output: [],
446
448
  stateCheckInterval: undefined,
447
449
  isPrimaryCommand: true,
@@ -499,6 +501,7 @@ describe('hookExecutor Integration Tests', () => {
499
501
  process: {},
500
502
  terminal: {},
501
503
  serializer: {},
504
+ restoreScrollbackBaseLine: 0,
502
505
  output: [],
503
506
  stateCheckInterval: undefined,
504
507
  isPrimaryCommand: true,
@@ -558,6 +561,7 @@ describe('hookExecutor Integration Tests', () => {
558
561
  process: {},
559
562
  terminal: {},
560
563
  serializer: {},
564
+ restoreScrollbackBaseLine: 0,
561
565
  output: [],
562
566
  stateCheckInterval: undefined,
563
567
  isPrimaryCommand: true,
@@ -133,6 +133,7 @@ describe('prepareSessionItems', () => {
133
133
  isActive: true,
134
134
  terminal: {},
135
135
  serializer: {},
136
+ restoreScrollbackBaseLine: 0,
136
137
  stateCheckInterval: undefined,
137
138
  isPrimaryCommand: true,
138
139
  presetName: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.6",
3
+ "version": "4.1.8",
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.6",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.6",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.6",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.6",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.6"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.8",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.8",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.8",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.8",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.8"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",