ccmanager 3.11.0 → 3.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,8 @@ import RemoteBranchSelector from './RemoteBranchSelector.js';
14
14
  import LoadingSpinner from './LoadingSpinner.js';
15
15
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
16
16
  import { WorktreeService } from '../services/worktreeService.js';
17
- import { worktreeNameGenerator } from '../services/worktreeNameGenerator.js';
17
+ import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
18
+ import { logger } from '../utils/logger.js';
18
19
  import { AmbiguousBranchError, } from '../types/index.js';
19
20
  import { configReader } from '../services/config/configReader.js';
20
21
  import { ENV_VARS } from '../constants/env.js';
@@ -53,26 +54,24 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
53
54
  }
54
55
  };
55
56
  // Helper function to create session with Effect-based error handling
56
- const createSessionWithEffect = async (worktreePath, presetId, initialPrompt) => {
57
+ const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
57
58
  const sessionEffect = devcontainerConfig
58
59
  ? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt)
59
60
  : sessionManager.createSessionWithPresetEffect(worktreePath, presetId, initialPrompt);
60
61
  // Execute the Effect and handle both success and failure cases
61
62
  const result = await Effect.runPromise(Effect.either(sessionEffect));
62
63
  if (result._tag === 'Left') {
63
- // Handle error using pattern matching on _tag
64
64
  const errorMessage = formatErrorMessage(result.left);
65
65
  return {
66
66
  success: false,
67
67
  errorMessage: `Failed to create session: ${errorMessage}`,
68
68
  };
69
69
  }
70
- // Success case - extract session from Right
71
70
  return {
72
71
  success: true,
73
72
  session: result.right,
74
73
  };
75
- };
74
+ }, [sessionManager, devcontainerConfig]);
76
75
  // Helper function to clear terminal screen
77
76
  const clearScreen = () => {
78
77
  if (process.stdout.isTTY) {
@@ -115,7 +114,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
115
114
  session = result.session;
116
115
  }
117
116
  navigateToSession(session);
118
- }, [sessionManager, navigateWithClear, navigateToSession]);
117
+ }, [
118
+ sessionManager,
119
+ navigateWithClear,
120
+ navigateToSession,
121
+ createSessionWithEffect,
122
+ ]);
119
123
  useEffect(() => {
120
124
  // Listen for session exits to return to menu automatically
121
125
  const handleSessionExit = (session) => {
@@ -324,11 +328,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
324
328
  const existingBranches = allBranches._tag === 'Right' ? allBranches.right : [];
325
329
  const generatedBranch = await Effect.runPromise(Effect.either(worktreeNameGenerator.generateBranchNameEffect(request.initialPrompt, request.baseBranch, existingBranches)));
326
330
  if (generatedBranch._tag === 'Left') {
327
- setError(formatErrorMessage(generatedBranch.left));
328
- setView('new-worktree');
329
- return;
331
+ logger.warn(`Branch name generation failed, using fallback: ${formatErrorMessage(generatedBranch.left)}`);
332
+ branch = generateFallbackBranchName(existingBranches);
333
+ }
334
+ else {
335
+ branch = generatedBranch.right;
330
336
  }
331
- branch = generatedBranch.right;
332
337
  if (request.autoDirectoryPattern) {
333
338
  targetPath = generateWorktreeDirectory(request.projectPath, branch, request.autoDirectoryPattern);
334
339
  }
@@ -11,7 +11,7 @@ import { WorktreeService } from '../services/worktreeService.js';
11
11
  import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
12
12
  import { useSearchMode } from '../hooks/useSearchMode.js';
13
13
  import { useGitStatus } from '../hooks/useGitStatus.js';
14
- import { truncateString, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
14
+ import { truncateString, calculateColumnPositions, assembleWorktreeLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
15
15
  import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
16
16
  import TextInputWrapper from './TextInputWrapper.js';
17
17
  const MAX_BRANCH_NAME_LENGTH = 70;
@@ -241,12 +241,18 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
241
241
  fileChanges,
242
242
  aheadBehind,
243
243
  parentBranch,
244
+ lastCommitDate: wt.lastCommitDate
245
+ ? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
246
+ : '',
244
247
  error: itemError,
245
248
  lengths: {
246
249
  base: stripAnsi(baseLabel).length,
247
250
  fileChanges: stripAnsi(fileChanges).length,
248
251
  aheadBehind: stripAnsi(aheadBehind).length,
249
252
  parentBranch: stripAnsi(parentBranch).length,
253
+ lastCommitDate: wt.lastCommitDate
254
+ ? formatRelativeDate(wt.lastCommitDate).length
255
+ : 0,
250
256
  },
251
257
  };
252
258
  });
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
+ import path from 'path';
3
4
  import { Box, Text, useInput } from 'ink';
4
5
  import SelectInput from 'ink-select-input';
5
6
  import { Effect } from 'effect';
@@ -20,8 +21,18 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
20
21
  try {
21
22
  const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
22
23
  if (!cancelled) {
23
- // Filter out main worktree - we shouldn't delete it
24
- const deletableWorktrees = allWorktrees.filter(wt => !wt.isMainWorktree);
24
+ // Filter out main worktree and current working directory worktree
25
+ const resolvedCwd = path.resolve(process.cwd());
26
+ const deletableWorktrees = allWorktrees.filter(wt => {
27
+ if (wt.isMainWorktree)
28
+ return false;
29
+ const resolvedPath = path.resolve(wt.path);
30
+ if (resolvedCwd === resolvedPath ||
31
+ resolvedCwd.startsWith(resolvedPath + path.sep)) {
32
+ return false;
33
+ }
34
+ return true;
35
+ });
25
36
  setWorktrees(deletableWorktrees);
26
37
  setIsLoading(false);
27
38
  }
@@ -144,7 +144,8 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
144
144
  sessionManager.isAutoApprovalDisabledForWorktree(item.worktree.path);
145
145
  const label = baseLabel + (aaDisabled ? ' [Auto Approval Off]' : '');
146
146
  // Only show numbers for worktrees (0-9) when not in search mode
147
- const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : '❯ ';
147
+ // Use fixed-width prefix to prevent flicker at scroll boundary
148
+ const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : ' ❯ ';
148
149
  return {
149
150
  type: 'worktree',
150
151
  label: numberPrefix + label,
@@ -5,9 +5,6 @@ interface TextInputWrapperProps {
5
5
  onSubmit?: (value: string) => void;
6
6
  placeholder?: string;
7
7
  focus?: boolean;
8
- mask?: string;
9
- showCursor?: boolean;
10
- highlightPastedText?: boolean;
11
8
  }
12
9
  declare const TextInputWrapper: React.FC<TextInputWrapperProps>;
13
10
  export default TextInputWrapper;
@@ -1,15 +1,124 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import TextInput from 'ink-text-input';
2
+ import { useReducer, useEffect, useRef, useMemo, } from 'react';
3
+ import { Text, useInput } from 'ink';
4
+ import chalk from 'chalk';
3
5
  import stripAnsi from 'strip-ansi';
4
- const TextInputWrapper = ({ value, onChange, ...props }) => {
5
- const handleChange = (newValue) => {
6
- // First strip all ANSI escape sequences
7
- let cleanedValue = stripAnsi(newValue);
8
- // Then specifically remove bracketed paste mode markers that might remain
9
- // These sometimes appear as literal text after ANSI stripping
10
- cleanedValue = cleanedValue.replace(/\[200~/g, '').replace(/\[201~/g, '');
11
- onChange(cleanedValue);
12
- };
13
- return _jsx(TextInput, { value: value, onChange: handleChange, ...props });
6
+ const reducer = (state, action) => {
7
+ switch (action.type) {
8
+ case 'move-cursor-left': {
9
+ return {
10
+ ...state,
11
+ cursorOffset: Math.max(0, state.cursorOffset - 1),
12
+ };
13
+ }
14
+ case 'move-cursor-right': {
15
+ return {
16
+ ...state,
17
+ cursorOffset: Math.min(state.value.length, state.cursorOffset + 1),
18
+ };
19
+ }
20
+ case 'insert': {
21
+ return {
22
+ value: state.value.slice(0, state.cursorOffset) +
23
+ action.text +
24
+ state.value.slice(state.cursorOffset),
25
+ cursorOffset: state.cursorOffset + action.text.length,
26
+ };
27
+ }
28
+ case 'delete': {
29
+ if (state.cursorOffset === 0)
30
+ return state;
31
+ const newOffset = state.cursorOffset - 1;
32
+ return {
33
+ value: state.value.slice(0, newOffset) + state.value.slice(newOffset + 1),
34
+ cursorOffset: newOffset,
35
+ };
36
+ }
37
+ case 'set': {
38
+ return {
39
+ value: action.value,
40
+ cursorOffset: action.value.length,
41
+ };
42
+ }
43
+ }
44
+ };
45
+ function cleanInput(input) {
46
+ let cleaned = stripAnsi(input);
47
+ cleaned = cleaned.replace(/\[200~/g, '').replace(/\[201~/g, '');
48
+ return cleaned;
49
+ }
50
+ const cursor = chalk.inverse(' ');
51
+ const TextInputWrapper = ({ value, onChange, onSubmit, placeholder = '', focus = true, }) => {
52
+ const [state, dispatch] = useReducer(reducer, {
53
+ value,
54
+ cursorOffset: value.length,
55
+ });
56
+ const lastReportedValue = useRef(value);
57
+ // Sync external value changes into internal state
58
+ useEffect(() => {
59
+ if (value !== lastReportedValue.current) {
60
+ lastReportedValue.current = value;
61
+ dispatch({ type: 'set', value });
62
+ }
63
+ }, [value]);
64
+ // Report internal state changes to parent
65
+ useEffect(() => {
66
+ if (state.value !== lastReportedValue.current) {
67
+ lastReportedValue.current = state.value;
68
+ onChange(state.value);
69
+ }
70
+ }, [state.value, onChange]);
71
+ useInput((input, key) => {
72
+ if (key.upArrow ||
73
+ key.downArrow ||
74
+ (key.ctrl && input === 'c') ||
75
+ key.tab ||
76
+ (key.shift && key.tab)) {
77
+ return;
78
+ }
79
+ if (key.return) {
80
+ onSubmit?.(state.value);
81
+ return;
82
+ }
83
+ if (key.leftArrow) {
84
+ dispatch({ type: 'move-cursor-left' });
85
+ }
86
+ else if (key.rightArrow) {
87
+ dispatch({ type: 'move-cursor-right' });
88
+ }
89
+ else if (key.backspace || key.delete) {
90
+ dispatch({ type: 'delete' });
91
+ }
92
+ else {
93
+ const cleaned = cleanInput(input);
94
+ if (cleaned) {
95
+ dispatch({ type: 'insert', text: cleaned });
96
+ }
97
+ }
98
+ }, { isActive: focus });
99
+ const renderedPlaceholder = useMemo(() => {
100
+ if (!focus) {
101
+ return placeholder ? chalk.dim(placeholder) : '';
102
+ }
103
+ return placeholder.length > 0
104
+ ? chalk.inverse(placeholder[0]) + chalk.dim(placeholder.slice(1))
105
+ : cursor;
106
+ }, [focus, placeholder]);
107
+ const renderedValue = useMemo(() => {
108
+ if (!focus) {
109
+ return state.value;
110
+ }
111
+ let result = state.value.length > 0 ? '' : cursor;
112
+ let index = 0;
113
+ for (const char of state.value) {
114
+ result += index === state.cursorOffset ? chalk.inverse(char) : char;
115
+ index++;
116
+ }
117
+ if (state.value.length > 0 && state.cursorOffset === state.value.length) {
118
+ result += cursor;
119
+ }
120
+ return result;
121
+ }, [focus, state.value, state.cursorOffset]);
122
+ return (_jsx(Text, { children: state.value.length > 0 ? renderedValue : renderedPlaceholder }));
14
123
  };
15
124
  export default TextInputWrapper;
@@ -164,18 +164,6 @@ describe('SessionManager', () => {
164
164
  expect(spawn).toHaveBeenCalledWith('opencode', ['run', '--prompt', 'implement prompt flow'], expect.any(Object));
165
165
  expect(mockPty.write).not.toHaveBeenCalled();
166
166
  });
167
- it('writes the initial prompt to stdin for unknown commands', async () => {
168
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
169
- id: '1',
170
- name: 'Custom',
171
- command: 'custom-agent',
172
- args: ['--interactive'],
173
- });
174
- vi.mocked(spawn).mockReturnValue(mockPty);
175
- await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', undefined, 'implement prompt flow'));
176
- expect(spawn).toHaveBeenCalledWith('custom-agent', ['--interactive'], expect.any(Object));
177
- expect(mockPty.write).toHaveBeenCalledWith('implement prompt flow\r');
178
- });
179
167
  it('should fall back to default preset if specified preset not found', async () => {
180
168
  // Setup mocks
181
169
  vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
@@ -2,6 +2,7 @@ import { Effect } from 'effect';
2
2
  import { ProcessError } from '../types/errors.js';
3
3
  export declare const extractBranchNameFromOutput: (stdout: string) => string;
4
4
  export declare const deduplicateBranchName: (name: string, existingBranches: string[]) => string;
5
+ export declare const generateFallbackBranchName: (existingBranches?: string[]) => string;
5
6
  export declare class WorktreeNameGenerator {
6
7
  generateBranchNameEffect(userPrompt: string, baseBranch: string, existingBranches?: string[]): Effect.Effect<string, ProcessError, never>;
7
8
  }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import { Effect } from 'effect';
2
3
  import { execFile } from 'child_process';
3
4
  import { ProcessError } from '../types/errors.js';
@@ -121,6 +122,15 @@ export const deduplicateBranchName = (name, existingBranches) => {
121
122
  }
122
123
  }
123
124
  };
125
+ export const generateFallbackBranchName = (existingBranches) => {
126
+ const date = new Date();
127
+ const dateStr = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
128
+ const randomSuffix = randomBytes(3).toString('hex');
129
+ const name = `${dateStr}-${randomSuffix}`;
130
+ return existingBranches
131
+ ? deduplicateBranchName(name, existingBranches)
132
+ : name;
133
+ };
124
134
  export class WorktreeNameGenerator {
125
135
  generateBranchNameEffect(userPrompt, baseBranch, existingBranches) {
126
136
  return Effect.tryPromise({
@@ -141,7 +151,15 @@ export class WorktreeNameGenerator {
141
151
  reject(new Error('Timed out while generating a branch name with `claude -p`'));
142
152
  });
143
153
  }, DEFAULT_TIMEOUT_MS);
144
- child = execFile('claude', ['-p', '--output-format', 'json', '--json-schema', JSON_SCHEMA], {
154
+ child = execFile('claude', [
155
+ '-p',
156
+ '--model',
157
+ 'haiku',
158
+ '--output-format',
159
+ 'json',
160
+ '--json-schema',
161
+ JSON_SCHEMA,
162
+ ], {
145
163
  encoding: 'utf8',
146
164
  maxBuffer: 1024 * 1024,
147
165
  }, (error, stdout) => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { deduplicateBranchName, extractBranchNameFromOutput, worktreeNameGenerator as generator, } from './worktreeNameGenerator.js';
2
+ import { deduplicateBranchName, extractBranchNameFromOutput, generateFallbackBranchName, worktreeNameGenerator as generator, } from './worktreeNameGenerator.js';
3
3
  describe('WorktreeNameGenerator output parsing', () => {
4
4
  it('normalizes direct json branchName responses', async () => {
5
5
  const value = extractBranchNameFromOutput('{"branchName":"feature/add prompt"}');
@@ -33,3 +33,15 @@ describe('deduplicateBranchName', () => {
33
33
  expect(deduplicateBranchName('Feature/New', ['feature/new'])).toBe('Feature/New-2');
34
34
  });
35
35
  });
36
+ describe('generateFallbackBranchName', () => {
37
+ it('returns a name matching YYYYMMDD-hex pattern', () => {
38
+ const name = generateFallbackBranchName();
39
+ expect(name).toMatch(/^\d{8}-[0-9a-f]{6}$/);
40
+ });
41
+ it('deduplicates against existing branches', () => {
42
+ const first = generateFallbackBranchName();
43
+ const name = generateFallbackBranchName([first]);
44
+ // Either it's different (random collision unlikely) or it has a -2 suffix
45
+ expect(name).not.toBe(first);
46
+ });
47
+ });
@@ -662,6 +662,21 @@ export class WorktreeService {
662
662
  if (mainWorktree && mainWorktree.path.includes('.git/modules')) {
663
663
  mainWorktree.path = self.gitRootPath;
664
664
  }
665
+ // Fetch last commit date for each worktree
666
+ for (const wt of worktrees) {
667
+ try {
668
+ const dateStr = execSync('git log -1 --format=%aI', {
669
+ cwd: wt.path,
670
+ encoding: 'utf8',
671
+ }).trim();
672
+ if (dateStr) {
673
+ wt.lastCommitDate = new Date(dateStr);
674
+ }
675
+ }
676
+ catch {
677
+ // Ignore errors (e.g., empty repo)
678
+ }
679
+ }
665
680
  // Sort worktrees by last session if requested
666
681
  if (sortByLastSession) {
667
682
  worktrees.sort((a, b) => {
@@ -900,6 +915,17 @@ export class WorktreeService {
900
915
  stderr: 'Cannot delete the main worktree',
901
916
  }));
902
917
  }
918
+ // Prevent deleting the worktree that contains the current working directory
919
+ const resolvedWorktreePath = path.resolve(worktreePath);
920
+ const resolvedCwd = path.resolve(process.cwd());
921
+ if (resolvedCwd === resolvedWorktreePath ||
922
+ resolvedCwd.startsWith(resolvedWorktreePath + path.sep)) {
923
+ return yield* Effect.fail(new GitError({
924
+ command: 'git worktree remove',
925
+ exitCode: 1,
926
+ stderr: `Cannot delete the worktree at "${worktreePath}" because it is the current working directory`,
927
+ }));
928
+ }
903
929
  // Remove the worktree
904
930
  yield* Effect.try({
905
931
  try: () => {
@@ -757,6 +757,36 @@ branch refs/heads/main
757
757
  expect.fail('Should have returned Left with GitError');
758
758
  }
759
759
  });
760
+ it('should return Effect that fails with GitError when trying to delete worktree matching cwd', async () => {
761
+ const cwdPath = process.cwd();
762
+ mockedExecSync.mockImplementation((cmd, _options) => {
763
+ if (typeof cmd === 'string') {
764
+ if (cmd === 'git rev-parse --git-common-dir') {
765
+ return '/fake/path/.git\n';
766
+ }
767
+ if (cmd === 'git worktree list --porcelain') {
768
+ return `worktree /fake/path
769
+ HEAD abcd1234
770
+ branch refs/heads/main
771
+
772
+ worktree ${cwdPath}
773
+ HEAD efgh5678
774
+ branch refs/heads/feature
775
+ `;
776
+ }
777
+ }
778
+ throw new Error('Command not mocked: ' + cmd);
779
+ });
780
+ const effect = service.deleteWorktreeEffect(cwdPath);
781
+ const result = await Effect.runPromise(Effect.either(effect));
782
+ if (result._tag === 'Left') {
783
+ expect(result.left).toBeInstanceOf(GitError);
784
+ expect(result.left.stderr).toContain('because it is the current working directory');
785
+ }
786
+ else {
787
+ expect.fail('Should have returned Left with GitError');
788
+ }
789
+ });
760
790
  });
761
791
  describe('Effect-based mergeWorktree', () => {
762
792
  it('should return Effect with void on successful merge', async () => {
@@ -13,6 +13,7 @@ export interface Worktree {
13
13
  hasSession: boolean;
14
14
  gitStatus?: GitStatus;
15
15
  gitStatusError?: string;
16
+ lastCommitDate?: Date;
16
17
  }
17
18
  export interface Session {
18
19
  id: string;
@@ -13,14 +13,16 @@ const PROMPT_FLAG = {
13
13
  'github-copilot': '-i',
14
14
  kimi: '-p',
15
15
  };
16
+ const DEFAULT_DETECTION_STRATEGY = 'claude';
16
17
  export const getPromptInjectionMethod = (preset) => {
17
- if (preset.detectionStrategy && PROMPT_FLAG[preset.detectionStrategy]) {
18
+ const strategy = preset.detectionStrategy ?? DEFAULT_DETECTION_STRATEGY;
19
+ if (PROMPT_FLAG[strategy]) {
18
20
  return 'flag';
19
21
  }
20
- if (preset.detectionStrategy === 'claude' ||
21
- preset.detectionStrategy === 'codex' ||
22
- preset.detectionStrategy === 'cursor' ||
23
- preset.detectionStrategy === 'cline') {
22
+ if (strategy === 'claude' ||
23
+ strategy === 'codex' ||
24
+ strategy === 'cursor' ||
25
+ strategy === 'cline') {
24
26
  return 'final-arg';
25
27
  }
26
28
  return 'stdin';
@@ -49,13 +49,6 @@ describe('presetPrompt', () => {
49
49
  method: 'flag',
50
50
  });
51
51
  });
52
- it('falls back to stdin for unknown commands', () => {
53
- expect(preparePresetLaunch({ command: 'custom-agent', args: ['--interactive'] }, 'hello')).toEqual({
54
- args: ['--interactive'],
55
- method: 'stdin',
56
- stdinPayload: 'hello\r',
57
- });
58
- });
59
52
  describe('describePromptInjection', () => {
60
53
  it('describes final-arg for claude', () => {
61
54
  expect(describePromptInjection({
@@ -105,9 +98,6 @@ describe('presetPrompt', () => {
105
98
  detectionStrategy: 'kimi',
106
99
  })).toContain('-p');
107
100
  });
108
- it('describes stdin for unknown strategy', () => {
109
- expect(describePromptInjection({ command: 'custom-agent' })).toContain('standard input');
110
- });
111
101
  });
112
102
  describe('getPromptInjectionMethod', () => {
113
103
  it('returns final-arg for claude', () => {
@@ -158,10 +148,8 @@ describe('presetPrompt', () => {
158
148
  detectionStrategy: 'kimi',
159
149
  })).toBe('flag');
160
150
  });
161
- it('returns stdin when detectionStrategy is not set', () => {
162
- expect(getPromptInjectionMethod({ command: 'claude' })).toBe('stdin');
163
- expect(getPromptInjectionMethod({ command: 'opencode' })).toBe('stdin');
164
- expect(getPromptInjectionMethod({ command: 'custom-agent' })).toBe('stdin');
151
+ it('falls back to claude strategy when detectionStrategy is not set', () => {
152
+ expect(getPromptInjectionMethod({ command: 'claude' })).toBe('final-arg');
165
153
  });
166
154
  });
167
155
  });
@@ -9,14 +9,20 @@ export interface WorktreeItem {
9
9
  fileChanges: string;
10
10
  aheadBehind: string;
11
11
  parentBranch: string;
12
+ lastCommitDate: string;
12
13
  error?: string;
13
14
  lengths: {
14
15
  base: number;
15
16
  fileChanges: number;
16
17
  aheadBehind: number;
17
18
  parentBranch: number;
19
+ lastCommitDate: number;
18
20
  };
19
21
  }
22
+ /**
23
+ * Format a date as a relative time string (e.g., "2h ago", "3d ago").
24
+ */
25
+ export declare function formatRelativeDate(date: Date): string;
20
26
  export declare function truncateString(str: string, maxLength: number): string;
21
27
  export declare function generateWorktreeDirectory(projectPath: string, branchName: string, pattern?: string): string;
22
28
  export declare function extractBranchParts(branchName: string): {
@@ -34,6 +40,7 @@ export declare function calculateColumnPositions(items: WorktreeItem[]): {
34
40
  fileChanges: number;
35
41
  aheadBehind: number;
36
42
  parentBranch: number;
43
+ lastCommitDate: number;
37
44
  };
38
45
  /**
39
46
  * Assembles the final worktree label with proper column alignment
@@ -6,6 +6,33 @@ import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from
6
6
  // Constants
7
7
  const MAX_BRANCH_NAME_LENGTH = 70; // Maximum characters for branch name display
8
8
  const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
9
+ /**
10
+ * Format a date as a relative time string (e.g., "2h ago", "3d ago").
11
+ */
12
+ export function formatRelativeDate(date) {
13
+ const now = Date.now();
14
+ const diffMs = now - date.getTime();
15
+ const diffSec = Math.floor(diffMs / 1000);
16
+ const diffMin = Math.floor(diffSec / 60);
17
+ const diffHour = Math.floor(diffMin / 60);
18
+ const diffDay = Math.floor(diffHour / 24);
19
+ const diffWeek = Math.floor(diffDay / 7);
20
+ const diffMonth = Math.floor(diffDay / 30);
21
+ const diffYear = Math.floor(diffDay / 365);
22
+ if (diffYear > 0)
23
+ return `${diffYear}y ago`;
24
+ if (diffMonth > 0)
25
+ return `${diffMonth}mo ago`;
26
+ if (diffWeek > 0)
27
+ return `${diffWeek}w ago`;
28
+ if (diffDay > 0)
29
+ return `${diffDay}d ago`;
30
+ if (diffHour > 0)
31
+ return `${diffHour}h ago`;
32
+ if (diffMin > 0)
33
+ return `${diffMin}m ago`;
34
+ return 'just now';
35
+ }
9
36
  // Utility function to truncate strings with ellipsis
10
37
  export function truncateString(str, maxLength) {
11
38
  if (str.length <= maxLength)
@@ -107,6 +134,10 @@ export function prepareWorktreeItems(worktrees, sessions) {
107
134
  // Show fetching status in dim gray
108
135
  fileChanges = '\x1b[90m[fetching...]\x1b[0m';
109
136
  }
137
+ // Format last commit date as dim relative time
138
+ const lastCommitDate = wt.lastCommitDate
139
+ ? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
140
+ : '';
110
141
  return {
111
142
  worktree: wt,
112
143
  session,
@@ -114,12 +145,14 @@ export function prepareWorktreeItems(worktrees, sessions) {
114
145
  fileChanges,
115
146
  aheadBehind,
116
147
  parentBranch,
148
+ lastCommitDate,
117
149
  error,
118
150
  lengths: {
119
151
  base: stripAnsi(baseLabel).length,
120
152
  fileChanges: stripAnsi(fileChanges).length,
121
153
  aheadBehind: stripAnsi(aheadBehind).length,
122
154
  parentBranch: stripAnsi(parentBranch).length,
155
+ lastCommitDate: stripAnsi(lastCommitDate).length,
123
156
  },
124
157
  };
125
158
  });
@@ -132,6 +165,7 @@ export function calculateColumnPositions(items) {
132
165
  let maxBranchLength = 0;
133
166
  let maxFileChangesLength = 0;
134
167
  let maxAheadBehindLength = 0;
168
+ let maxParentBranchLength = 0;
135
169
  items.forEach(item => {
136
170
  // Skip items with errors for alignment calculation
137
171
  if (item.error)
@@ -139,15 +173,18 @@ export function calculateColumnPositions(items) {
139
173
  maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
140
174
  maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
141
175
  maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
176
+ maxParentBranchLength = Math.max(maxParentBranchLength, item.lengths.parentBranch);
142
177
  });
143
178
  // Simple column positioning
144
179
  const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
145
180
  const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
146
181
  const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
182
+ const lastCommitDateColumn = parentBranchColumn + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
147
183
  return {
148
184
  fileChanges: fileChangesColumn,
149
185
  aheadBehind: aheadBehindColumn,
150
186
  parentBranch: parentBranchColumn,
187
+ lastCommitDate: lastCommitDateColumn,
151
188
  };
152
189
  }
153
190
  // Pad string to column position
@@ -175,6 +212,11 @@ export function assembleWorktreeLabel(item, columns) {
175
212
  if (item.parentBranch) {
176
213
  label =
177
214
  padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
215
+ currentLength = columns.parentBranch + item.lengths.parentBranch;
216
+ }
217
+ if (item.lastCommitDate) {
218
+ label =
219
+ padTo(label, currentLength, columns.lastCommitDate) + item.lastCommitDate;
178
220
  }
179
221
  return label;
180
222
  }
@@ -170,11 +170,13 @@ describe('column alignment', () => {
170
170
  fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
171
171
  aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
172
172
  parentBranch: '',
173
+ lastCommitDate: '',
173
174
  lengths: {
174
175
  base: 19, // 'feature/test-branch'.length
175
176
  fileChanges: 6, // '+10 -5'.length
176
177
  aheadBehind: 5, // '↑2 ↓3'.length
177
178
  parentBranch: 0,
179
+ lastCommitDate: 0,
178
180
  },
179
181
  },
180
182
  {
@@ -183,11 +185,13 @@ describe('column alignment', () => {
183
185
  fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
184
186
  aheadBehind: '\x1b[33m↑1\x1b[0m',
185
187
  parentBranch: '',
188
+ lastCommitDate: '',
186
189
  lengths: {
187
190
  base: 4, // 'main'.length
188
191
  fileChanges: 5, // '+2 -1'.length
189
192
  aheadBehind: 2, // '↑1'.length
190
193
  parentBranch: 0,
194
+ lastCommitDate: 0,
191
195
  },
192
196
  },
193
197
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.11.0",
3
+ "version": "3.11.2",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "3.11.0",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.11.0",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.11.0",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.11.0",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.11.0"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.11.2",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.11.2",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.11.2",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.11.2",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.11.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",