ccmanager 4.3.2 → 4.4.0

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.
Files changed (38) hide show
  1. package/README.md +42 -0
  2. package/dist/components/App.js +46 -21
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/Menu.js +6 -1
  6. package/dist/components/RestoreSessions.d.ts +19 -0
  7. package/dist/components/RestoreSessions.js +28 -0
  8. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  9. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  10. package/dist/services/config/globalConfigManager.js +3 -12
  11. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  12. package/dist/services/globalSessionOrchestrator.js +40 -0
  13. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  14. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  15. package/dist/services/projectManager.js +3 -11
  16. package/dist/services/sessionManager.d.ts +6 -0
  17. package/dist/services/sessionManager.js +16 -0
  18. package/dist/services/sessionRestoreStore.d.ts +75 -0
  19. package/dist/services/sessionRestoreStore.js +138 -0
  20. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  21. package/dist/services/sessionRestoreStore.test.js +92 -0
  22. package/dist/services/sessionRestorer.d.ts +46 -0
  23. package/dist/services/sessionRestorer.js +123 -0
  24. package/dist/services/sessionRestorer.test.d.ts +1 -0
  25. package/dist/services/sessionRestorer.test.js +163 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/utils/configDir.d.ts +10 -0
  28. package/dist/utils/configDir.js +31 -0
  29. package/dist/utils/errorMessage.d.ts +7 -0
  30. package/dist/utils/errorMessage.js +19 -0
  31. package/dist/utils/filterByQuery.test.js +2 -0
  32. package/dist/utils/gitUtils.d.ts +1 -0
  33. package/dist/utils/gitUtils.js +15 -0
  34. package/dist/utils/hookExecutor.test.js +4 -0
  35. package/dist/utils/worktreeUtils.d.ts +25 -4
  36. package/dist/utils/worktreeUtils.js +53 -17
  37. package/dist/utils/worktreeUtils.test.js +62 -4
  38. package/package.json +6 -6
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { mkdtempSync, mkdirSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import path from 'path';
6
+ import { ProcessError, ValidationError } from '../types/errors.js';
7
+ const storedRecords = [];
8
+ const forgetMock = vi.fn((...ids) => {
9
+ for (const id of ids) {
10
+ const index = storedRecords.findIndex(record => record.id === id);
11
+ if (index !== -1) {
12
+ storedRecords.splice(index, 1);
13
+ }
14
+ }
15
+ });
16
+ vi.mock('./sessionRestoreStore.js', () => ({
17
+ sessionRestoreStore: {
18
+ list: () => [...storedRecords],
19
+ forget: (...ids) => forgetMock(...ids),
20
+ },
21
+ }));
22
+ class MockSessionManager {
23
+ createSessionWithPresetEffect = vi.fn((_worktreePath, _presetId) => Effect.succeed({ id: 'new-session' }));
24
+ createSessionWithDevcontainerEffect = vi.fn((_worktreePath, _devcontainerConfig, _presetId) => Effect.succeed({ id: 'new-session' }));
25
+ renameSession = vi.fn((_id, _name) => { });
26
+ }
27
+ const managersByProject = new Map();
28
+ const getManagerForProjectMock = vi.fn((projectPath) => {
29
+ let manager = managersByProject.get(projectPath);
30
+ if (!manager) {
31
+ manager = new MockSessionManager();
32
+ managersByProject.set(projectPath, manager);
33
+ }
34
+ return manager;
35
+ });
36
+ vi.mock('./globalSessionOrchestrator.js', () => ({
37
+ globalSessionOrchestrator: {
38
+ getManagerForProject: (projectPath) => getManagerForProjectMock(projectPath),
39
+ },
40
+ }));
41
+ vi.mock('./config/configReader.js', () => ({
42
+ configReader: {
43
+ getPresetByIdEffect: (id) => id === 'preset-1'
44
+ ? Either.right({ id: 'preset-1', name: 'Main' })
45
+ : Either.left(new ValidationError({
46
+ field: 'presetId',
47
+ constraint: 'Preset not found',
48
+ receivedValue: id,
49
+ })),
50
+ getDefaultPreset: () => ({ id: 'default', name: 'Default' }),
51
+ },
52
+ }));
53
+ const { listRestorableSessions, restoreSessions, discardRestorableSessions, describeRecordPreset, } = await import('./sessionRestorer.js');
54
+ describe('sessionRestorer', () => {
55
+ let existingWorktree;
56
+ const record = (overrides = {}) => ({
57
+ id: 'session-1',
58
+ projectPath: '/repo',
59
+ worktreePath: existingWorktree,
60
+ presetId: 'preset-1',
61
+ // A process id that is not running, so the record counts as restorable.
62
+ ownerPid: 999999,
63
+ createdAt: 1,
64
+ ...overrides,
65
+ });
66
+ beforeEach(() => {
67
+ const directory = mkdtempSync(path.join(tmpdir(), 'ccmanager-restore-'));
68
+ existingWorktree = path.join(directory, 'feature');
69
+ mkdirSync(existingWorktree);
70
+ storedRecords.length = 0;
71
+ forgetMock.mockClear();
72
+ managersByProject.clear();
73
+ getManagerForProjectMock.mockClear();
74
+ });
75
+ describe('listRestorableSessions', () => {
76
+ it('offers a recorded session whose worktree still exists', () => {
77
+ storedRecords.push(record());
78
+ expect(listRestorableSessions().map(r => r.id)).toEqual(['session-1']);
79
+ });
80
+ it('skips a session whose worktree has been deleted', () => {
81
+ storedRecords.push(record({ worktreePath: '/gone/worktree' }));
82
+ expect(listRestorableSessions()).toEqual([]);
83
+ });
84
+ it('skips a session still owned by a running ccmanager', () => {
85
+ storedRecords.push(record({ ownerPid: process.pid }));
86
+ expect(listRestorableSessions()).toEqual([]);
87
+ });
88
+ it('only offers the requested project when a project path is given', () => {
89
+ storedRecords.push(record({ id: 'mine', projectPath: '/repo' }));
90
+ storedRecords.push(record({ id: 'other', projectPath: '/elsewhere' }));
91
+ expect(listRestorableSessions({ projectPath: '/repo' }).map(r => r.id)).toEqual(['mine']);
92
+ });
93
+ it('returns oldest first so sessions come back in the order they were opened', () => {
94
+ storedRecords.push(record({ id: 'second', createdAt: 20 }));
95
+ storedRecords.push(record({ id: 'first', createdAt: 10 }));
96
+ expect(listRestorableSessions().map(r => r.id)).toEqual([
97
+ 'first',
98
+ 'second',
99
+ ]);
100
+ });
101
+ });
102
+ describe('restoreSessions', () => {
103
+ it('launches the recorded preset in the recorded worktree', async () => {
104
+ const target = record();
105
+ const outcome = await restoreSessions([target], { multiProject: false });
106
+ const manager = managersByProject.get(undefined);
107
+ expect(manager.createSessionWithPresetEffect).toHaveBeenCalledWith(existingWorktree, 'preset-1');
108
+ expect(outcome).toEqual({ restored: 1, failures: [] });
109
+ });
110
+ it('drops the old record so the same session is not offered twice', async () => {
111
+ storedRecords.push(record());
112
+ await restoreSessions([record()], { multiProject: false });
113
+ expect(forgetMock).toHaveBeenCalledWith('session-1');
114
+ expect(storedRecords).toEqual([]);
115
+ });
116
+ it('restores the session name the user had given', async () => {
117
+ await restoreSessions([record({ sessionName: 'review' })], {
118
+ multiProject: false,
119
+ });
120
+ const manager = managersByProject.get(undefined);
121
+ expect(manager.renameSession).toHaveBeenCalledWith('new-session', 'review');
122
+ });
123
+ it('uses the manager of the recorded project in multi-project mode', async () => {
124
+ await restoreSessions([record()], { multiProject: true });
125
+ expect(getManagerForProjectMock).toHaveBeenCalledWith('/repo');
126
+ });
127
+ it('relaunches through the devcontainer when the session used one', async () => {
128
+ const devcontainerConfig = {
129
+ upCommand: 'devcontainer up',
130
+ execCommand: 'devcontainer exec',
131
+ };
132
+ await restoreSessions([record({ devcontainerConfig })], {
133
+ multiProject: false,
134
+ });
135
+ const manager = managersByProject.get(undefined);
136
+ expect(manager.createSessionWithDevcontainerEffect).toHaveBeenCalledWith(existingWorktree, devcontainerConfig, 'preset-1');
137
+ });
138
+ it('reports a failed session and carries on with the rest', async () => {
139
+ const failing = record({ id: 'failing' });
140
+ const succeeding = record({ id: 'succeeding' });
141
+ const manager = getManagerForProjectMock(undefined);
142
+ manager.createSessionWithPresetEffect.mockImplementationOnce(() => Effect.fail(new ProcessError({ command: 'claude', message: 'spawn failed' })));
143
+ const outcome = await restoreSessions([failing, succeeding], {
144
+ multiProject: false,
145
+ });
146
+ expect(outcome.restored).toBe(1);
147
+ expect(outcome.failures).toHaveLength(1);
148
+ expect(outcome.failures[0]?.record.id).toBe('failing');
149
+ expect(outcome.failures[0]?.message).toContain('spawn failed');
150
+ });
151
+ });
152
+ it('describes a record by its current preset name, falling back to the default', () => {
153
+ expect(describeRecordPreset(record())).toBe('Main');
154
+ expect(describeRecordPreset(record({ presetId: 'removed' }))).toBe('Default');
155
+ expect(describeRecordPreset(record({ presetId: undefined }))).toBe('Default');
156
+ });
157
+ it('forgets the sessions the user declined to restore', () => {
158
+ storedRecords.push(record({ id: 'a' }), record({ id: 'b' }));
159
+ discardRestorableSessions([record({ id: 'a' }), record({ id: 'b' })]);
160
+ expect(forgetMock).toHaveBeenCalledWith('a', 'b');
161
+ expect(storedRecords).toEqual([]);
162
+ });
163
+ });
@@ -40,6 +40,7 @@ export interface Session {
40
40
  stateCheckInterval: NodeJS.Timeout | undefined;
41
41
  isPrimaryCommand: boolean;
42
42
  presetName: string | undefined;
43
+ presetId: string | undefined;
43
44
  detectionStrategy: StateDetectionStrategy | undefined;
44
45
  devcontainerConfig: DevcontainerConfig | undefined;
45
46
  /**
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Path of ccmanager's configuration/state directory. Does not touch the
3
+ * filesystem.
4
+ */
5
+ export declare function getConfigDir(): string;
6
+ /**
7
+ * Path of ccmanager's configuration/state directory, creating it first if it
8
+ * does not exist yet.
9
+ */
10
+ export declare function ensureConfigDir(): string;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview Single source of truth for the location of ccmanager's own
3
+ * configuration/state directory (`~/.config/ccmanager`, or the Windows
4
+ * equivalent under APPDATA). Every module that needs to read or write a file
5
+ * belonging to ccmanager itself resolves the directory through here so the
6
+ * location is defined in exactly one place.
7
+ */
8
+ import { homedir } from 'os';
9
+ import path from 'path';
10
+ import { existsSync, mkdirSync } from 'fs';
11
+ /**
12
+ * Path of ccmanager's configuration/state directory. Does not touch the
13
+ * filesystem.
14
+ */
15
+ export function getConfigDir() {
16
+ const homeDir = homedir();
17
+ return process.platform === 'win32'
18
+ ? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
19
+ : path.join(homeDir, '.config', 'ccmanager');
20
+ }
21
+ /**
22
+ * Path of ccmanager's configuration/state directory, creating it first if it
23
+ * does not exist yet.
24
+ */
25
+ export function ensureConfigDir() {
26
+ const configDir = getConfigDir();
27
+ if (!existsSync(configDir)) {
28
+ mkdirSync(configDir, { recursive: true });
29
+ }
30
+ return configDir;
31
+ }
@@ -0,0 +1,7 @@
1
+ import { type AppError } from '../types/errors.js';
2
+ /**
3
+ * Human-readable one-line rendering of an application error, for display in the
4
+ * TUI and for log lines. Each error type carries different fields, so the text
5
+ * is composed per type via the `_tag` discriminator.
6
+ */
7
+ export declare function formatErrorMessage(error: AppError): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Human-readable one-line rendering of an application error, for display in the
3
+ * TUI and for log lines. Each error type carries different fields, so the text
4
+ * is composed per type via the `_tag` discriminator.
5
+ */
6
+ export function formatErrorMessage(error) {
7
+ switch (error._tag) {
8
+ case 'ProcessError':
9
+ return `Process error: ${error.message}`;
10
+ case 'ConfigError':
11
+ return `Configuration error (${error.reason}): ${error.details}`;
12
+ case 'GitError':
13
+ return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
14
+ case 'FileSystemError':
15
+ return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
16
+ case 'ValidationError':
17
+ return `Validation failed for ${error.field}: ${error.constraint}`;
18
+ }
19
+ }
@@ -8,6 +8,7 @@ const makeItem = (searchableName, path) => ({
8
8
  hasSession: false,
9
9
  },
10
10
  baseLabel: searchableName,
11
+ status: '',
11
12
  searchableName,
12
13
  fileChanges: '',
13
14
  aheadBehind: '',
@@ -15,6 +16,7 @@ const makeItem = (searchableName, path) => ({
15
16
  lastCommitDate: '',
16
17
  lengths: {
17
18
  base: 0,
19
+ status: 0,
18
20
  fileChanges: 0,
19
21
  aheadBehind: 0,
20
22
  parentBranch: 0,
@@ -15,3 +15,4 @@ export declare function hasUncommittedChanges(worktreePath: string): boolean;
15
15
  * @returns The absolute path to the git repository root, or null if not in a git repo
16
16
  */
17
17
  export declare function getGitRepositoryRoot(cwd: string): string | null;
18
+ export declare function getCurrentRepositoryRoot(): string;
@@ -62,3 +62,18 @@ export function getGitRepositoryRoot(cwd) {
62
62
  return null;
63
63
  }
64
64
  }
65
+ /**
66
+ * Repository root of the directory ccmanager was started in.
67
+ *
68
+ * Cached after the first call: it cannot change while the process runs, and
69
+ * both the code that records sessions for restore and the code that looks them
70
+ * up need the exact same value to agree on which project a session belongs to.
71
+ */
72
+ let currentRepositoryRoot;
73
+ export function getCurrentRepositoryRoot() {
74
+ if (currentRepositoryRoot === undefined) {
75
+ const cwd = process.cwd();
76
+ currentRepositoryRoot = getGitRepositoryRoot(cwd) ?? path.resolve(cwd);
77
+ }
78
+ return currentRepositoryRoot;
79
+ }
@@ -393,6 +393,7 @@ describe('hookExecutor Integration Tests', () => {
393
393
  stateCheckInterval: undefined,
394
394
  isPrimaryCommand: true,
395
395
  presetName: undefined,
396
+ presetId: undefined,
396
397
  detectionStrategy: 'claude',
397
398
  devcontainerConfig: undefined,
398
399
  lastActivity: new Date(),
@@ -453,6 +454,7 @@ describe('hookExecutor Integration Tests', () => {
453
454
  stateCheckInterval: undefined,
454
455
  isPrimaryCommand: true,
455
456
  presetName: undefined,
457
+ presetId: undefined,
456
458
  detectionStrategy: 'claude',
457
459
  devcontainerConfig: undefined,
458
460
  lastActivity: new Date(),
@@ -511,6 +513,7 @@ describe('hookExecutor Integration Tests', () => {
511
513
  stateCheckInterval: undefined,
512
514
  isPrimaryCommand: true,
513
515
  presetName: undefined,
516
+ presetId: undefined,
514
517
  detectionStrategy: 'claude',
515
518
  devcontainerConfig: undefined,
516
519
  lastActivity: new Date(),
@@ -571,6 +574,7 @@ describe('hookExecutor Integration Tests', () => {
571
574
  stateCheckInterval: undefined,
572
575
  isPrimaryCommand: true,
573
576
  presetName: undefined,
577
+ presetId: undefined,
574
578
  detectionStrategy: 'claude',
575
579
  devcontainerConfig: undefined,
576
580
  lastActivity: new Date(),
@@ -6,6 +6,7 @@ export interface SessionItem {
6
6
  worktree: Worktree;
7
7
  session?: Session;
8
8
  baseLabel: string;
9
+ status: string;
9
10
  searchableName: string;
10
11
  fileChanges: string;
11
12
  aheadBehind: string;
@@ -14,6 +15,7 @@ export interface SessionItem {
14
15
  error?: string;
15
16
  lengths: {
16
17
  base: number;
18
+ status: number;
17
19
  fileChanges: number;
18
20
  aheadBehind: number;
19
21
  parentBranch: number;
@@ -66,18 +68,37 @@ export declare function prepareSessionItems(worktrees: Worktree[], sessions: Ses
66
68
  sortByLastSession?: boolean;
67
69
  }): SessionItem[];
68
70
  /**
69
- * Calculates column positions based on content widths.
71
+ * Column start positions for one rendered list, plus how the session state tag
72
+ * (e.g. "[○ Idle]") is placed.
70
73
  */
71
- export declare function calculateColumnPositions(items: SessionItem[]): {
74
+ export interface ColumnPositions {
72
75
  fileChanges: number;
73
76
  aheadBehind: number;
74
77
  parentBranch: number;
78
+ status: number;
75
79
  lastCommitDate: number;
76
- };
80
+ /**
81
+ * true: the state tag gets its own column at `status`, so every row's tag
82
+ * starts at the same horizontal position, directly left of the commit date.
83
+ * false: the state tag is appended right after the branch name (the layout
84
+ * used before this column existed), because the aligned layout would not fit
85
+ * the terminal width given to calculateColumnPositions.
86
+ */
87
+ alignStatus: boolean;
88
+ }
89
+ /**
90
+ * Calculates column positions based on content widths.
91
+ *
92
+ * `availableWidth` is the number of terminal columns the label may occupy
93
+ * (i.e. terminal width minus whatever prefix the caller prepends). When the
94
+ * aligned-status layout would not fit in it, the layout falls back to appending
95
+ * the state tag to the name, which is narrower. Omit it to always align.
96
+ */
97
+ export declare function calculateColumnPositions(items: SessionItem[], availableWidth?: number): ColumnPositions;
77
98
  /**
78
99
  * Assembles the final worktree label with proper column alignment
79
100
  */
80
- export declare function assembleSessionLabel(item: SessionItem, columns: ReturnType<typeof calculateColumnPositions>): string;
101
+ export declare function assembleSessionLabel(item: SessionItem, columns: ColumnPositions): string;
81
102
  /**
82
103
  * Whether a worktree may be deleted by CCManager.
83
104
  *
@@ -206,7 +206,7 @@ function gitStatusColumns(wt, fullBranchName) {
206
206
  function buildSessionItem(wt, session, sessionSuffix) {
207
207
  const stateData = session?.stateMutex.getSnapshot();
208
208
  const status = stateData
209
- ? ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
209
+ ? `[${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
210
210
  : '';
211
211
  const fullBranchName = wt.branch
212
212
  ? wt.branch.replace('refs/heads/', '')
@@ -214,7 +214,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
214
214
  const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
215
215
  const isMain = wt.isMainWorktree ? ' (main)' : '';
216
216
  const { displaySuffix: dirSuffix, rawName: rawDirName } = formatWorktreeDirectorySuffix(wt, fullBranchName);
217
- const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}${status}`;
217
+ const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}`;
218
218
  // Use the full (untruncated) branch name so search still matches the tail
219
219
  // of long branch names; status icons are excluded so they don't match.
220
220
  const rawDirForSearch = rawDirName ? ` @ ${rawDirName}` : '';
@@ -227,6 +227,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
227
227
  worktree: wt,
228
228
  session,
229
229
  baseLabel,
230
+ status,
230
231
  searchableName,
231
232
  fileChanges,
232
233
  aheadBehind,
@@ -235,6 +236,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
235
236
  error,
236
237
  lengths: {
237
238
  base: stripAnsi(baseLabel).length,
239
+ status: stripAnsi(status).length,
238
240
  fileChanges: stripAnsi(fileChanges).length,
239
241
  aheadBehind: stripAnsi(aheadBehind).length,
240
242
  parentBranch: stripAnsi(parentBranch).length,
@@ -277,35 +279,59 @@ export function prepareSessionItems(worktrees, sessions, options) {
277
279
  }
278
280
  return items;
279
281
  }
282
+ /**
283
+ * Visible width of the name portion when the state tag is appended to it
284
+ * instead of getting its own column.
285
+ */
286
+ function inlineBaseLength(item) {
287
+ return (item.lengths.base + (item.lengths.status ? item.lengths.status + 1 : 0));
288
+ }
280
289
  /**
281
290
  * Calculates column positions based on content widths.
291
+ *
292
+ * `availableWidth` is the number of terminal columns the label may occupy
293
+ * (i.e. terminal width minus whatever prefix the caller prepends). When the
294
+ * aligned-status layout would not fit in it, the layout falls back to appending
295
+ * the state tag to the name, which is narrower. Omit it to always align.
282
296
  */
283
- export function calculateColumnPositions(items) {
297
+ export function calculateColumnPositions(items, availableWidth) {
284
298
  // Calculate maximum widths from pre-calculated lengths
285
299
  let maxBranchLength = 0;
300
+ let maxInlineBranchLength = 0;
301
+ let maxStatusLength = 0;
286
302
  let maxFileChangesLength = 0;
287
303
  let maxAheadBehindLength = 0;
288
304
  let maxParentBranchLength = 0;
305
+ let maxLastCommitDateLength = 0;
289
306
  items.forEach(item => {
290
307
  // Skip items with errors for alignment calculation
291
308
  if (item.error)
292
309
  return;
293
310
  maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
311
+ maxInlineBranchLength = Math.max(maxInlineBranchLength, inlineBaseLength(item));
312
+ maxStatusLength = Math.max(maxStatusLength, item.lengths.status);
294
313
  maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
295
314
  maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
296
315
  maxParentBranchLength = Math.max(maxParentBranchLength, item.lengths.parentBranch);
316
+ maxLastCommitDateLength = Math.max(maxLastCommitDateLength, item.lengths.lastCommitDate);
297
317
  });
298
- // Simple column positioning
299
- const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
300
- const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
301
- const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
302
- const lastCommitDateColumn = parentBranchColumn + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
303
- return {
304
- fileChanges: fileChangesColumn,
305
- aheadBehind: aheadBehindColumn,
306
- parentBranch: parentBranchColumn,
307
- lastCommitDate: lastCommitDateColumn,
318
+ // Simple column positioning. `branchWidth` is how much room the name portion
319
+ // gets, `statusWidth` is 0 when the state tag rides along with the name.
320
+ const layout = (branchWidth, statusWidth) => {
321
+ const fileChanges = branchWidth + MIN_COLUMN_PADDING;
322
+ const aheadBehind = fileChanges + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
323
+ const parentBranch = aheadBehind + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
324
+ const status = parentBranch + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
325
+ const lastCommitDate = status + (statusWidth ? statusWidth + MIN_COLUMN_PADDING : 0);
326
+ return { fileChanges, aheadBehind, parentBranch, status, lastCommitDate };
308
327
  };
328
+ const aligned = layout(maxBranchLength, maxStatusLength);
329
+ const fits = availableWidth === undefined ||
330
+ aligned.lastCommitDate + maxLastCommitDateLength <= availableWidth;
331
+ if (fits) {
332
+ return { ...aligned, alignStatus: true };
333
+ }
334
+ return { ...layout(maxInlineBranchLength, 0), alignStatus: false };
309
335
  }
310
336
  // Pad string to column position
311
337
  function padTo(str, visibleLength, column) {
@@ -315,12 +341,18 @@ function padTo(str, visibleLength, column) {
315
341
  * Assembles the final worktree label with proper column alignment
316
342
  */
317
343
  export function assembleSessionLabel(item, columns) {
318
- // If there's an error, just show the base label with error appended
344
+ const inlineStatus = item.status
345
+ ? `${item.baseLabel} ${item.status}`
346
+ : item.baseLabel;
347
+ // If there's an error, just show the base label with error appended.
348
+ // Error rows carry no columns at all, so the state tag stays next to the name.
319
349
  if (item.error) {
320
- return `${item.baseLabel} ${item.error}`;
350
+ return `${inlineStatus} ${item.error}`;
321
351
  }
322
- let label = item.baseLabel;
323
- let currentLength = item.lengths.base;
352
+ let label = columns.alignStatus ? item.baseLabel : inlineStatus;
353
+ let currentLength = columns.alignStatus
354
+ ? item.lengths.base
355
+ : inlineBaseLength(item);
324
356
  if (item.fileChanges) {
325
357
  label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges;
326
358
  currentLength = columns.fileChanges + item.lengths.fileChanges;
@@ -334,6 +366,10 @@ export function assembleSessionLabel(item, columns) {
334
366
  padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
335
367
  currentLength = columns.parentBranch + item.lengths.parentBranch;
336
368
  }
369
+ if (columns.alignStatus && item.status) {
370
+ label = padTo(label, currentLength, columns.status) + item.status;
371
+ currentLength = columns.status + item.lengths.status;
372
+ }
337
373
  if (item.lastCommitDate) {
338
374
  label =
339
375
  padTo(label, currentLength, columns.lastCommitDate) + item.lastCommitDate;
@@ -139,6 +139,7 @@ describe('prepareSessionItems', () => {
139
139
  stateCheckInterval: undefined,
140
140
  isPrimaryCommand: true,
141
141
  presetName: undefined,
142
+ presetId: undefined,
142
143
  detectionStrategy: 'claude',
143
144
  devcontainerConfig: undefined,
144
145
  stateMutex: new Mutex({
@@ -152,9 +153,12 @@ describe('prepareSessionItems', () => {
152
153
  expect(items).toHaveLength(1);
153
154
  expect(items[0]?.baseLabel).toBe('feature/test-branch');
154
155
  });
155
- it('should include session status in label', () => {
156
+ it('should expose the session status separately from the name', () => {
156
157
  const items = prepareSessionItems([mockWorktree], [mockSession]);
157
- expect(items[0]?.baseLabel).toContain('[○ Idle]');
158
+ // The status tag is its own field so it can be rendered as an aligned
159
+ // column; it must not be baked into the name portion.
160
+ expect(items[0]?.status).toBe('[○ Idle]');
161
+ expect(items[0]?.baseLabel).toBe('feature/test-branch');
158
162
  });
159
163
  it('should mark main worktree', () => {
160
164
  const mainWorktree = { ...mockWorktree, isMainWorktree: true };
@@ -249,8 +253,9 @@ describe('prepareSessionItems', () => {
249
253
  sessionName: 'lab',
250
254
  },
251
255
  ]);
252
- // Order must be: branch, dir suffix, (no main), session suffix, status.
253
- expect(items[0]?.baseLabel).toMatch(/^feature\/foo @ foo-api: lab \[.*Idle.*\]$/);
256
+ // Order must be: branch, dir suffix, (no main), session suffix.
257
+ expect(items[0]?.baseLabel).toBe('feature/foo @ foo-api: lab');
258
+ expect(items[0]?.status).toMatch(/^\[.*Idle.*\]$/);
254
259
  });
255
260
  it('does not break column alignment when a dir suffix is appended', () => {
256
261
  const items = prepareSessionItems([
@@ -277,6 +282,7 @@ describe('column alignment', () => {
277
282
  {
278
283
  worktree: {},
279
284
  baseLabel: 'feature/test-branch',
285
+ status: '',
280
286
  searchableName: 'feature/test-branch',
281
287
  fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
282
288
  aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
@@ -284,6 +290,7 @@ describe('column alignment', () => {
284
290
  lastCommitDate: '',
285
291
  lengths: {
286
292
  base: 19, // 'feature/test-branch'.length
293
+ status: 0,
287
294
  fileChanges: 6, // '+10 -5'.length
288
295
  aheadBehind: 5, // '↑2 ↓3'.length
289
296
  parentBranch: 0,
@@ -293,6 +300,7 @@ describe('column alignment', () => {
293
300
  {
294
301
  worktree: {},
295
302
  baseLabel: 'main',
303
+ status: '',
296
304
  searchableName: 'main',
297
305
  fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
298
306
  aheadBehind: '\x1b[33m↑1\x1b[0m',
@@ -300,6 +308,7 @@ describe('column alignment', () => {
300
308
  lastCommitDate: '',
301
309
  lengths: {
302
310
  base: 4, // 'main'.length
311
+ status: 0,
303
312
  fileChanges: 5, // '+2 -1'.length
304
313
  aheadBehind: 2, // '↑1'.length
305
314
  parentBranch: 0,
@@ -344,3 +353,52 @@ describe('isDeletableWorktree', () => {
344
353
  expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo')).toBe(true);
345
354
  });
346
355
  });
356
+ describe('session status column', () => {
357
+ const makeItem = (baseLabel, status, lastCommitDate) => ({
358
+ worktree: {},
359
+ baseLabel,
360
+ status,
361
+ searchableName: baseLabel,
362
+ fileChanges: '',
363
+ aheadBehind: '',
364
+ parentBranch: '',
365
+ lastCommitDate,
366
+ lengths: {
367
+ base: baseLabel.length,
368
+ status: status.length,
369
+ fileChanges: 0,
370
+ aheadBehind: 0,
371
+ parentBranch: 0,
372
+ lastCommitDate: lastCommitDate.length,
373
+ },
374
+ });
375
+ const items = [
376
+ makeItem('feature/a-very-long-branch-name', '[○ Idle]', '1d ago'),
377
+ makeItem('main', '[● Busy]', '3w ago'),
378
+ ];
379
+ it('starts every status tag at the same column, just left of the date', () => {
380
+ const columns = calculateColumnPositions(items, 120);
381
+ expect(columns.alignStatus).toBe(true);
382
+ const labels = items.map(item => assembleSessionLabel(item, columns));
383
+ for (const [index, label] of labels.entries()) {
384
+ expect(label.indexOf(items[index].status)).toBe(columns.status);
385
+ expect(label.indexOf(items[index].lastCommitDate)).toBe(columns.lastCommitDate);
386
+ }
387
+ // The gap between the tag and the date is only the column padding.
388
+ expect(columns.lastCommitDate - columns.status).toBe('[○ Idle]'.length + 2);
389
+ });
390
+ it('falls back to appending the status to the name when too narrow', () => {
391
+ const columns = calculateColumnPositions(items, 40);
392
+ expect(columns.alignStatus).toBe(false);
393
+ expect(assembleSessionLabel(items[0], columns)).toContain('feature/a-very-long-branch-name [○ Idle]');
394
+ expect(assembleSessionLabel(items[1], columns)).toContain('main [● Busy]');
395
+ });
396
+ it('keeps the status next to the name on rows showing a git error', () => {
397
+ const errored = {
398
+ ...makeItem('main', '[○ Idle]', ''),
399
+ error: '[git error]',
400
+ };
401
+ const columns = calculateColumnPositions([...items, errored], 120);
402
+ expect(assembleSessionLabel(errored, columns)).toBe('main [○ Idle] [git error]');
403
+ });
404
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.3.2",
3
+ "version": "4.4.0",
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.3.2",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.3.2",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.3.2",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.3.2",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.3.2"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.4.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.4.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.4.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.4.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.4.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",