ccmanager 4.1.17 → 4.1.19
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.
- package/dist/components/App.js +11 -1
- package/dist/components/Dashboard.d.ts +1 -0
- package/dist/components/Dashboard.js +25 -5
- package/dist/components/Dashboard.test.js +288 -4
- package/dist/components/Session.js +6 -2
- package/dist/components/Session.test.js +52 -0
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +52 -14
- package/dist/services/sessionManager.test.js +96 -4
- package/dist/utils/worktreeUtils.d.ts +1 -0
- package/dist/utils/worktreeUtils.js +1 -1
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -572,6 +572,16 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
572
572
|
setActiveSession(session);
|
|
573
573
|
navigateWithClear('session');
|
|
574
574
|
};
|
|
575
|
+
const handleSessionActionFromDashboard = (session, project) => {
|
|
576
|
+
const projectSessionManager = globalSessionOrchestrator.getManagerForProject(project.path);
|
|
577
|
+
setSessionManager(projectSessionManager);
|
|
578
|
+
setWorktreeService(new WorktreeService(project.path));
|
|
579
|
+
setSessionActionsTarget({
|
|
580
|
+
session,
|
|
581
|
+
worktreePath: session.worktreePath,
|
|
582
|
+
});
|
|
583
|
+
navigateWithClear('session-actions');
|
|
584
|
+
};
|
|
575
585
|
const handleBackToProjectList = () => {
|
|
576
586
|
// Sessions persist in their project-specific managers
|
|
577
587
|
setSelectedProject(null);
|
|
@@ -587,7 +597,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
587
597
|
if (!projectsDir) {
|
|
588
598
|
return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: ["Error: ", MULTI_PROJECT_ERRORS.NO_PROJECTS_DIR] }) }));
|
|
589
599
|
}
|
|
590
|
-
return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null), version: version }));
|
|
600
|
+
return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, onSessionAction: handleSessionActionFromDashboard, error: error, onDismissError: () => setError(null), version: version }));
|
|
591
601
|
}
|
|
592
602
|
if (view === 'menu') {
|
|
593
603
|
return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
|
|
@@ -4,6 +4,7 @@ interface DashboardProps {
|
|
|
4
4
|
projectsDir: string;
|
|
5
5
|
onSelectSession: (session: ISession, project: GitProject) => void;
|
|
6
6
|
onSelectProject: (project: GitProject) => void;
|
|
7
|
+
onSessionAction?: (session: ISession, project: GitProject) => void;
|
|
7
8
|
error: string | null;
|
|
8
9
|
onDismissError: () => void;
|
|
9
10
|
version: string;
|
|
@@ -12,7 +12,7 @@ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../c
|
|
|
12
12
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
13
13
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
14
14
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
15
|
-
import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
|
|
15
|
+
import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, displaySuffix, } from '../utils/worktreeUtils.js';
|
|
16
16
|
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
|
|
17
17
|
import SearchableList from './SearchableList.js';
|
|
18
18
|
const MAX_BRANCH_NAME_LENGTH = 70;
|
|
@@ -63,7 +63,7 @@ function resolveProjectDisplayNames(projects) {
|
|
|
63
63
|
}
|
|
64
64
|
return displayNames;
|
|
65
65
|
}
|
|
66
|
-
const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDismissError, version, }) => {
|
|
66
|
+
const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAction, error, onDismissError, version, }) => {
|
|
67
67
|
const [projects, setProjects] = useState([]);
|
|
68
68
|
const [recentProjects, setRecentProjects] = useState([]);
|
|
69
69
|
const [loading, setLoading] = useState(true);
|
|
@@ -73,6 +73,8 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
73
73
|
const [sessionEntries, setSessionEntries] = useState([]);
|
|
74
74
|
const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
|
|
75
75
|
const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
|
|
76
|
+
const [highlightedSession, setHighlightedSession] = useState(null);
|
|
77
|
+
const [highlightedProject, setHighlightedProject] = useState(null);
|
|
76
78
|
const displayError = error || loadError;
|
|
77
79
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
78
80
|
isDisabled: !!displayError,
|
|
@@ -220,7 +222,10 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
220
222
|
: wt.path.split('/').pop() || 'detached';
|
|
221
223
|
const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
|
|
222
224
|
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
223
|
-
const
|
|
225
|
+
const worktreeSessionCount = sessionEntries.filter(e => e.worktree.path === entry.worktree.path &&
|
|
226
|
+
e.projectPath === entry.projectPath).length;
|
|
227
|
+
const sessionSuffix = displaySuffix(entry.session, worktreeSessionCount > 1);
|
|
228
|
+
const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}${status}`;
|
|
224
229
|
let fileChanges = '';
|
|
225
230
|
let aheadBehind = '';
|
|
226
231
|
let parentBranch = '';
|
|
@@ -407,6 +412,11 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
407
412
|
return;
|
|
408
413
|
}
|
|
409
414
|
switch (keyPressed) {
|
|
415
|
+
case ' ':
|
|
416
|
+
if (highlightedSession && highlightedProject && onSessionAction) {
|
|
417
|
+
onSessionAction(highlightedSession, highlightedProject);
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
410
420
|
case 'r':
|
|
411
421
|
refreshAll();
|
|
412
422
|
break;
|
|
@@ -447,10 +457,20 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
447
457
|
if (!item)
|
|
448
458
|
return;
|
|
449
459
|
handleSelect(item);
|
|
460
|
+
}, onHighlight: raw => {
|
|
461
|
+
const item = items.find(i => i.value === raw?.value);
|
|
462
|
+
if (item?.type === 'session') {
|
|
463
|
+
setHighlightedSession(item.session);
|
|
464
|
+
setHighlightedProject(item.project);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
setHighlightedSession(null);
|
|
468
|
+
setHighlightedProject(null);
|
|
469
|
+
}
|
|
450
470
|
}, isFocused: !displayError, limit: limit, initialIndex: selectedIndex }) })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
|
|
451
471
|
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
452
472
|
: searchQuery
|
|
453
|
-
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
|
|
454
|
-
: 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
|
|
473
|
+
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Space-Session actions (session rows only) R-Refresh Q-Quit`
|
|
474
|
+
: 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) R-Refresh Q-Quit' })] })] }));
|
|
455
475
|
};
|
|
456
476
|
export default Dashboard;
|
|
@@ -1,26 +1,50 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { render } from 'ink-testing-library';
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
const capturedHandlers = { inputHandlers: [], onHighlight: null };
|
|
5
|
+
const makeKey = (overrides = {}) => ({
|
|
6
|
+
upArrow: false,
|
|
7
|
+
downArrow: false,
|
|
8
|
+
leftArrow: false,
|
|
9
|
+
rightArrow: false,
|
|
10
|
+
pageDown: false,
|
|
11
|
+
pageUp: false,
|
|
12
|
+
home: false,
|
|
13
|
+
end: false,
|
|
14
|
+
return: false,
|
|
15
|
+
escape: false,
|
|
16
|
+
ctrl: false,
|
|
17
|
+
shift: false,
|
|
18
|
+
tab: false,
|
|
19
|
+
backspace: false,
|
|
20
|
+
delete: false,
|
|
21
|
+
meta: false,
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
4
24
|
// Mock bunTerminal to avoid native module loading issues
|
|
5
25
|
vi.mock('../services/bunTerminal.js', () => ({
|
|
6
26
|
spawn: vi.fn(function () {
|
|
7
27
|
return null;
|
|
8
28
|
}),
|
|
9
29
|
}));
|
|
10
|
-
// Import the actual component code but
|
|
30
|
+
// Import the actual component code but capture useInput handlers
|
|
11
31
|
vi.mock('ink', async () => {
|
|
12
32
|
const actual = await vi.importActual('ink');
|
|
13
33
|
return {
|
|
14
34
|
...actual,
|
|
15
|
-
useInput: vi.fn()
|
|
35
|
+
useInput: vi.fn((handler) => {
|
|
36
|
+
capturedHandlers.inputHandlers.push(handler);
|
|
37
|
+
}),
|
|
16
38
|
};
|
|
17
39
|
});
|
|
18
|
-
// Mock SelectInput to render items as simple text
|
|
40
|
+
// Mock SelectInput to render items as simple text and capture onHighlight
|
|
19
41
|
vi.mock('ink-select-input', async () => {
|
|
20
42
|
const React = await vi.importActual('react');
|
|
21
43
|
const { Text, Box } = await vi.importActual('ink');
|
|
22
44
|
return {
|
|
23
|
-
default: ({ items }) => {
|
|
45
|
+
default: ({ items, onHighlight, }) => {
|
|
46
|
+
if (onHighlight)
|
|
47
|
+
capturedHandlers.onHighlight = onHighlight;
|
|
24
48
|
return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
|
|
25
49
|
},
|
|
26
50
|
};
|
|
@@ -120,6 +144,8 @@ describe('Dashboard', () => {
|
|
|
120
144
|
];
|
|
121
145
|
beforeEach(() => {
|
|
122
146
|
vi.clearAllMocks();
|
|
147
|
+
capturedHandlers.inputHandlers = [];
|
|
148
|
+
capturedHandlers.onHighlight = null;
|
|
123
149
|
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
|
|
124
150
|
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([]);
|
|
125
151
|
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([]);
|
|
@@ -308,6 +334,7 @@ describe('Dashboard', () => {
|
|
|
308
334
|
const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
309
335
|
expect(lastFrame()).toContain('Controls:');
|
|
310
336
|
expect(lastFrame()).toContain('0-9 Quick Select');
|
|
337
|
+
expect(lastFrame()).toContain('Space-Session actions');
|
|
311
338
|
expect(lastFrame()).toContain('R-Refresh');
|
|
312
339
|
expect(lastFrame()).toContain('Q-Quit');
|
|
313
340
|
});
|
|
@@ -345,4 +372,261 @@ describe('Dashboard', () => {
|
|
|
345
372
|
// shared-lib should appear first (index 0) because it's recent
|
|
346
373
|
expect(frame).toContain('0 ❯ shared-lib');
|
|
347
374
|
});
|
|
375
|
+
it('should show session name suffix in label when session has a name', async () => {
|
|
376
|
+
const mockSession = {
|
|
377
|
+
id: 'session-named',
|
|
378
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
379
|
+
sessionNumber: 1,
|
|
380
|
+
sessionName: 'my-feature',
|
|
381
|
+
lastActivity: new Date(),
|
|
382
|
+
isActive: true,
|
|
383
|
+
stateMutex: {
|
|
384
|
+
getSnapshot: () => ({
|
|
385
|
+
state: 'busy',
|
|
386
|
+
backgroundTaskCount: 0,
|
|
387
|
+
teamMemberCount: 0,
|
|
388
|
+
}),
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
|
|
392
|
+
'/projects/my-app',
|
|
393
|
+
]);
|
|
394
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
|
|
395
|
+
mockSession,
|
|
396
|
+
]);
|
|
397
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
398
|
+
return {
|
|
399
|
+
getWorktreesEffect: () => Effect.succeed([
|
|
400
|
+
{
|
|
401
|
+
path: '/projects/my-app/worktrees/feature-auth',
|
|
402
|
+
branch: 'feature/auth',
|
|
403
|
+
isMainWorktree: false,
|
|
404
|
+
hasSession: true,
|
|
405
|
+
},
|
|
406
|
+
]),
|
|
407
|
+
getGitRootPath: () => '/projects/my-app',
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
411
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
412
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
413
|
+
await vi.waitFor(() => {
|
|
414
|
+
return lastFrame()?.includes('Active Sessions') ?? false;
|
|
415
|
+
}, { timeout: 3000 });
|
|
416
|
+
expect(lastFrame()).toContain('my-app :: feature/auth: my-feature');
|
|
417
|
+
});
|
|
418
|
+
it('should show session number suffix when multiple unnamed sessions on same worktree', async () => {
|
|
419
|
+
const mockSessions = [
|
|
420
|
+
{
|
|
421
|
+
id: 'session-1',
|
|
422
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
423
|
+
sessionNumber: 1,
|
|
424
|
+
lastActivity: new Date(),
|
|
425
|
+
isActive: true,
|
|
426
|
+
stateMutex: {
|
|
427
|
+
getSnapshot: () => ({
|
|
428
|
+
state: 'busy',
|
|
429
|
+
backgroundTaskCount: 0,
|
|
430
|
+
teamMemberCount: 0,
|
|
431
|
+
}),
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'session-2',
|
|
436
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
437
|
+
sessionNumber: 2,
|
|
438
|
+
lastActivity: new Date(),
|
|
439
|
+
isActive: true,
|
|
440
|
+
stateMutex: {
|
|
441
|
+
getSnapshot: () => ({
|
|
442
|
+
state: 'idle',
|
|
443
|
+
backgroundTaskCount: 0,
|
|
444
|
+
teamMemberCount: 0,
|
|
445
|
+
}),
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
|
|
450
|
+
'/projects/my-app',
|
|
451
|
+
]);
|
|
452
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue(mockSessions);
|
|
453
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
454
|
+
return {
|
|
455
|
+
getWorktreesEffect: () => Effect.succeed([
|
|
456
|
+
{
|
|
457
|
+
path: '/projects/my-app/worktrees/feature-auth',
|
|
458
|
+
branch: 'feature/auth',
|
|
459
|
+
isMainWorktree: false,
|
|
460
|
+
hasSession: true,
|
|
461
|
+
},
|
|
462
|
+
]),
|
|
463
|
+
getGitRootPath: () => '/projects/my-app',
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
467
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
468
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
469
|
+
await vi.waitFor(() => {
|
|
470
|
+
return lastFrame()?.includes('Active Sessions') ?? false;
|
|
471
|
+
}, { timeout: 3000 });
|
|
472
|
+
const frame = lastFrame();
|
|
473
|
+
expect(frame).toContain('#1');
|
|
474
|
+
expect(frame).toContain('#2');
|
|
475
|
+
});
|
|
476
|
+
it('should show no session suffix for single unnamed session', async () => {
|
|
477
|
+
const mockSession = {
|
|
478
|
+
id: 'session-single',
|
|
479
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
480
|
+
sessionNumber: 1,
|
|
481
|
+
lastActivity: new Date(),
|
|
482
|
+
isActive: true,
|
|
483
|
+
stateMutex: {
|
|
484
|
+
getSnapshot: () => ({
|
|
485
|
+
state: 'busy',
|
|
486
|
+
backgroundTaskCount: 0,
|
|
487
|
+
teamMemberCount: 0,
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
|
|
492
|
+
'/projects/my-app',
|
|
493
|
+
]);
|
|
494
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
|
|
495
|
+
mockSession,
|
|
496
|
+
]);
|
|
497
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
498
|
+
return {
|
|
499
|
+
getWorktreesEffect: () => Effect.succeed([
|
|
500
|
+
{
|
|
501
|
+
path: '/projects/my-app/worktrees/feature-auth',
|
|
502
|
+
branch: 'feature/auth',
|
|
503
|
+
isMainWorktree: false,
|
|
504
|
+
hasSession: true,
|
|
505
|
+
},
|
|
506
|
+
]),
|
|
507
|
+
getGitRootPath: () => '/projects/my-app',
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
511
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
512
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
513
|
+
await vi.waitFor(() => {
|
|
514
|
+
return lastFrame()?.includes('Active Sessions') ?? false;
|
|
515
|
+
}, { timeout: 3000 });
|
|
516
|
+
const frame = lastFrame();
|
|
517
|
+
expect(frame).toContain('my-app :: feature/auth');
|
|
518
|
+
expect(frame).not.toContain('feature/auth:');
|
|
519
|
+
expect(frame).not.toContain('feature/auth #');
|
|
520
|
+
});
|
|
521
|
+
it('should call onSessionAction when space is pressed on highlighted session', async () => {
|
|
522
|
+
const mockOnSessionAction = vi.fn();
|
|
523
|
+
const mockSession = {
|
|
524
|
+
id: 'session-action',
|
|
525
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
526
|
+
sessionNumber: 1,
|
|
527
|
+
lastActivity: new Date(),
|
|
528
|
+
isActive: true,
|
|
529
|
+
stateMutex: {
|
|
530
|
+
getSnapshot: () => ({
|
|
531
|
+
state: 'busy',
|
|
532
|
+
backgroundTaskCount: 0,
|
|
533
|
+
teamMemberCount: 0,
|
|
534
|
+
}),
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
|
|
538
|
+
'/projects/my-app',
|
|
539
|
+
]);
|
|
540
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
|
|
541
|
+
mockSession,
|
|
542
|
+
]);
|
|
543
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
544
|
+
return {
|
|
545
|
+
getWorktreesEffect: () => Effect.succeed([
|
|
546
|
+
{
|
|
547
|
+
path: '/projects/my-app/worktrees/feature-auth',
|
|
548
|
+
branch: 'feature/auth',
|
|
549
|
+
isMainWorktree: false,
|
|
550
|
+
hasSession: true,
|
|
551
|
+
},
|
|
552
|
+
]),
|
|
553
|
+
getGitRootPath: () => '/projects/my-app',
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
557
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
558
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
559
|
+
await vi.waitFor(() => {
|
|
560
|
+
return lastFrame()?.includes('Active Sessions') ?? false;
|
|
561
|
+
}, { timeout: 3000 });
|
|
562
|
+
// Enable the useInput handler (Dashboard guards on process.stdin.setRawMode)
|
|
563
|
+
const origSetRawMode = process.stdin.setRawMode;
|
|
564
|
+
process.stdin.setRawMode = vi.fn();
|
|
565
|
+
// Simulate highlighting a session item
|
|
566
|
+
expect(capturedHandlers.onHighlight).not.toBeNull();
|
|
567
|
+
capturedHandlers.onHighlight({
|
|
568
|
+
value: 'session-session-action',
|
|
569
|
+
label: 'my-app :: feature/auth',
|
|
570
|
+
});
|
|
571
|
+
// Force re-render to flush state update — new useInput handler captures updated closure
|
|
572
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
573
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
574
|
+
// Use the latest Dashboard handler (last in array — has updated highlightedSession state)
|
|
575
|
+
const latestHandler = capturedHandlers.inputHandlers[capturedHandlers.inputHandlers.length - 1];
|
|
576
|
+
expect(latestHandler).toBeDefined();
|
|
577
|
+
latestHandler(' ', makeKey());
|
|
578
|
+
expect(mockOnSessionAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'session-action' }), expect.objectContaining({ path: '/projects/my-app' }));
|
|
579
|
+
process.stdin.setRawMode = origSetRawMode;
|
|
580
|
+
});
|
|
581
|
+
it('should not call onSessionAction when space is pressed with no highlighted session', async () => {
|
|
582
|
+
const mockOnSessionAction = vi.fn();
|
|
583
|
+
const mockSession = {
|
|
584
|
+
id: 'session-noop',
|
|
585
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
586
|
+
sessionNumber: 1,
|
|
587
|
+
lastActivity: new Date(),
|
|
588
|
+
isActive: true,
|
|
589
|
+
stateMutex: {
|
|
590
|
+
getSnapshot: () => ({
|
|
591
|
+
state: 'busy',
|
|
592
|
+
backgroundTaskCount: 0,
|
|
593
|
+
teamMemberCount: 0,
|
|
594
|
+
}),
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
|
|
598
|
+
'/projects/my-app',
|
|
599
|
+
]);
|
|
600
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
|
|
601
|
+
mockSession,
|
|
602
|
+
]);
|
|
603
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
604
|
+
return {
|
|
605
|
+
getWorktreesEffect: () => Effect.succeed([
|
|
606
|
+
{
|
|
607
|
+
path: '/projects/my-app/worktrees/feature-auth',
|
|
608
|
+
branch: 'feature/auth',
|
|
609
|
+
isMainWorktree: false,
|
|
610
|
+
hasSession: true,
|
|
611
|
+
},
|
|
612
|
+
]),
|
|
613
|
+
getGitRootPath: () => '/projects/my-app',
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
617
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
618
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
619
|
+
await vi.waitFor(() => {
|
|
620
|
+
return lastFrame()?.includes('Active Sessions') ?? false;
|
|
621
|
+
}, { timeout: 3000 });
|
|
622
|
+
// Enable the useInput handler (Dashboard guards on process.stdin.setRawMode)
|
|
623
|
+
const origSetRawMode = process.stdin.setRawMode;
|
|
624
|
+
process.stdin.setRawMode = vi.fn();
|
|
625
|
+
// Do NOT call onHighlight — highlightedSession stays null
|
|
626
|
+
const dashboardHandler = capturedHandlers.inputHandlers.find(handler => handler !== capturedHandlers.inputHandlers[0]);
|
|
627
|
+
expect(dashboardHandler).toBeDefined();
|
|
628
|
+
dashboardHandler(' ', makeKey());
|
|
629
|
+
expect(mockOnSessionAction).not.toHaveBeenCalled();
|
|
630
|
+
process.stdin.setRawMode = origSetRawMode;
|
|
631
|
+
});
|
|
348
632
|
});
|
|
@@ -45,6 +45,8 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
45
45
|
return;
|
|
46
46
|
// Check for return to menu shortcut
|
|
47
47
|
if (shortcutManager.matchesRawInput('returnToMenu', data)) {
|
|
48
|
+
isExitingRef.current = true;
|
|
49
|
+
sessionManager.setSessionActive(session.id, false);
|
|
48
50
|
// Disable any extended input modes that might have been enabled by the PTY
|
|
49
51
|
if (stdout) {
|
|
50
52
|
resetTerminalInputModes();
|
|
@@ -72,7 +74,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
72
74
|
// restore and the deferred restore that may fire after Session.tsx
|
|
73
75
|
// has already disabled DECAWM for live TUI redraws.
|
|
74
76
|
const handleSessionRestore = (restoredSession, restoreSnapshot) => {
|
|
75
|
-
if (restoredSession.id === session.id) {
|
|
77
|
+
if (restoredSession.id === session.id && !isExitingRef.current) {
|
|
76
78
|
if (restoreSnapshot.length > 0) {
|
|
77
79
|
stdout.write(`\x1b[?7h${restoreSnapshot}\x1b[?7l`);
|
|
78
80
|
}
|
|
@@ -86,7 +88,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
86
88
|
// appends below the (already-clipped) viewport, producing duplicated
|
|
87
89
|
// rows equal to the resize delta.
|
|
88
90
|
const handleSessionResize = (resizedSession, redrawPayload) => {
|
|
89
|
-
if (resizedSession.id === session.id &&
|
|
91
|
+
if (resizedSession.id === session.id &&
|
|
92
|
+
redrawPayload.length > 0 &&
|
|
93
|
+
!isExitingRef.current) {
|
|
90
94
|
stdout.write(redrawPayload);
|
|
91
95
|
}
|
|
92
96
|
};
|
|
@@ -99,4 +99,56 @@ describe('Session', () => {
|
|
|
99
99
|
expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\x1b[?7h\nrestored\x1b[?7l');
|
|
100
100
|
expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\x1b[?7l');
|
|
101
101
|
});
|
|
102
|
+
it('detaches synchronously when returning to menu so late session output cannot repaint', async () => {
|
|
103
|
+
const listeners = new Map();
|
|
104
|
+
const session = {
|
|
105
|
+
id: 'session-1',
|
|
106
|
+
process: {
|
|
107
|
+
write: vi.fn(),
|
|
108
|
+
resize: vi.fn(),
|
|
109
|
+
},
|
|
110
|
+
terminal: {
|
|
111
|
+
resize: vi.fn(),
|
|
112
|
+
},
|
|
113
|
+
stateMutex: {
|
|
114
|
+
getSnapshot: () => ({ state: 'busy' }),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const onReturnToMenu = vi.fn();
|
|
118
|
+
const setSessionActive = vi.fn();
|
|
119
|
+
const sessionManager = {
|
|
120
|
+
on: vi.fn((event, handler) => {
|
|
121
|
+
const handlers = listeners.get(event) ?? new Set();
|
|
122
|
+
handlers.add(handler);
|
|
123
|
+
listeners.set(event, handlers);
|
|
124
|
+
return sessionManager;
|
|
125
|
+
}),
|
|
126
|
+
off: vi.fn((event, handler) => {
|
|
127
|
+
listeners.get(event)?.delete(handler);
|
|
128
|
+
return sessionManager;
|
|
129
|
+
}),
|
|
130
|
+
setSessionActive,
|
|
131
|
+
cancelAutoApproval: vi.fn(),
|
|
132
|
+
performResize: vi.fn(),
|
|
133
|
+
};
|
|
134
|
+
const { unmount } = render(_jsx(Session, { session: session, sessionManager: sessionManager, onReturnToMenu: onReturnToMenu }));
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
136
|
+
testState.stdout?.write.mockClear();
|
|
137
|
+
process.stdin.emit('data', '\u0005');
|
|
138
|
+
for (const handler of listeners.get('sessionData') ?? []) {
|
|
139
|
+
handler(session, 'late-data');
|
|
140
|
+
}
|
|
141
|
+
for (const handler of listeners.get('sessionRestore') ?? []) {
|
|
142
|
+
handler(session, 'late-restore');
|
|
143
|
+
}
|
|
144
|
+
for (const handler of listeners.get('sessionResize') ?? []) {
|
|
145
|
+
handler(session, 'late-resize');
|
|
146
|
+
}
|
|
147
|
+
expect(setSessionActive).toHaveBeenCalledWith(session.id, false);
|
|
148
|
+
expect(onReturnToMenu).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(testState.stdout?.write).not.toHaveBeenCalledWith('late-data');
|
|
150
|
+
expect(testState.stdout?.write).not.toHaveBeenCalledWith('\x1b[?7hlate-restore\x1b[?7l');
|
|
151
|
+
expect(testState.stdout?.write).not.toHaveBeenCalledWith('late-resize');
|
|
152
|
+
unmount();
|
|
153
|
+
});
|
|
102
154
|
});
|
|
@@ -22,6 +22,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
22
22
|
private resizeSuppressTimers;
|
|
23
23
|
private restoreDeferTimers;
|
|
24
24
|
private restoreDeferDeadlines;
|
|
25
|
+
private cursorRedrawSessions;
|
|
26
|
+
private cursorRedrawTimers;
|
|
25
27
|
private spawn;
|
|
26
28
|
private resolvePreset;
|
|
27
29
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -42,6 +44,9 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
42
44
|
constructor();
|
|
43
45
|
private createTerminal;
|
|
44
46
|
private shouldResetRestoreScrollback;
|
|
47
|
+
private hasCursorAddressedRedraw;
|
|
48
|
+
private markCursorRedrawActive;
|
|
49
|
+
private handleScrollbackGrowthDuringRedraw;
|
|
45
50
|
private getRestoreSnapshot;
|
|
46
51
|
private getViewportRedrawSnapshot;
|
|
47
52
|
/**
|
|
@@ -89,6 +94,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
89
94
|
private armRestoreDeferTimer;
|
|
90
95
|
private fireRestoreDefer;
|
|
91
96
|
private cancelRestoreDefer;
|
|
97
|
+
private clearCursorRedrawTracking;
|
|
92
98
|
cancelAutoApproval(sessionId: string, reason?: string): void;
|
|
93
99
|
toggleAutoApprovalForWorktree(worktreePath: string): boolean;
|
|
94
100
|
isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
|
|
@@ -20,7 +20,7 @@ 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 =
|
|
23
|
+
const TERMINAL_RESTORE_SCROLLBACK_LINES = TERMINAL_SCROLLBACK_LINES;
|
|
24
24
|
// How long to suppress PTY → stdout forwarding after a viewport resize so the
|
|
25
25
|
// child process's SIGWINCH-triggered re-emission of static content does not
|
|
26
26
|
// duplicate already-displayed rows in the user's terminal.
|
|
@@ -33,6 +33,11 @@ const RESIZE_SUPPRESS_MS = 250;
|
|
|
33
33
|
// streaming session still restores within a small bounded delay.
|
|
34
34
|
const RESTORE_DEFER_QUIET_MS = 80;
|
|
35
35
|
const RESTORE_DEFER_MAX_MS = 250;
|
|
36
|
+
// Cursor-addressed redraws (progress bars, spinners, TUIs) can leave transient
|
|
37
|
+
// rows in scrollback if the viewport scrolls before those rows are overwritten.
|
|
38
|
+
// Keep a short generic redraw window so restore can skip that ghost-bearing
|
|
39
|
+
// range without depending on any specific CLI output text.
|
|
40
|
+
const REDRAW_SCROLLBACK_QUIET_MS = 500;
|
|
36
41
|
export class SessionManager extends EventEmitter {
|
|
37
42
|
sessions;
|
|
38
43
|
waitingWithBottomBorder = new Map();
|
|
@@ -44,6 +49,8 @@ export class SessionManager extends EventEmitter {
|
|
|
44
49
|
resizeSuppressTimers = new Map();
|
|
45
50
|
restoreDeferTimers = new Map();
|
|
46
51
|
restoreDeferDeadlines = new Map();
|
|
52
|
+
cursorRedrawSessions = new Set();
|
|
53
|
+
cursorRedrawTimers = new Map();
|
|
47
54
|
async spawn(command, args, worktreePath, options = {}) {
|
|
48
55
|
const spawnOptions = {
|
|
49
56
|
name: 'xterm-256color',
|
|
@@ -231,6 +238,35 @@ export class SessionManager extends EventEmitter {
|
|
|
231
238
|
data.includes('\x1b[3J') ||
|
|
232
239
|
data.includes('\x1bc'));
|
|
233
240
|
}
|
|
241
|
+
hasCursorAddressedRedraw(data) {
|
|
242
|
+
return (
|
|
243
|
+
// CSI cursor movement/positioning, erase, and scroll controls.
|
|
244
|
+
/\x1b\[[0-?]*[ -/]*[ABCDEFGHJKSTX`abcdefg]/.test(data) ||
|
|
245
|
+
// DEC save/restore cursor.
|
|
246
|
+
/\x1b[78]/.test(data) ||
|
|
247
|
+
// Synchronized output mode usually brackets full-frame redraws.
|
|
248
|
+
/\x1b\[\?2026[hl]/.test(data));
|
|
249
|
+
}
|
|
250
|
+
markCursorRedrawActive(session) {
|
|
251
|
+
this.cursorRedrawSessions.add(session.id);
|
|
252
|
+
const existing = this.cursorRedrawTimers.get(session.id);
|
|
253
|
+
if (existing !== undefined) {
|
|
254
|
+
clearTimeout(existing);
|
|
255
|
+
}
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
this.cursorRedrawTimers.delete(session.id);
|
|
258
|
+
this.cursorRedrawSessions.delete(session.id);
|
|
259
|
+
}, REDRAW_SCROLLBACK_QUIET_MS);
|
|
260
|
+
this.cursorRedrawTimers.set(session.id, timer);
|
|
261
|
+
}
|
|
262
|
+
handleScrollbackGrowthDuringRedraw(session, beforeBaseY) {
|
|
263
|
+
const afterBaseY = session.terminal.buffer.normal.baseY;
|
|
264
|
+
if (afterBaseY <= beforeBaseY ||
|
|
265
|
+
!this.cursorRedrawSessions.has(session.id)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
session.restoreScrollbackBaseLine = Math.max(session.restoreScrollbackBaseLine, afterBaseY);
|
|
269
|
+
}
|
|
234
270
|
getRestoreSnapshot(session) {
|
|
235
271
|
const activeBuffer = session.terminal.buffer.active;
|
|
236
272
|
if (activeBuffer.type !== 'normal') {
|
|
@@ -245,19 +281,6 @@ export class SessionManager extends EventEmitter {
|
|
|
245
281
|
}
|
|
246
282
|
const cursorRow = normalBuffer.cursorY + 1;
|
|
247
283
|
const cursorCol = normalBuffer.cursorX + 1;
|
|
248
|
-
// When the live viewport shows a transient footer (spinner activity,
|
|
249
|
-
// token stats, persistent shift+tab footer, etc.), the renderer keeps
|
|
250
|
-
// redrawing it in place and earlier copies have likely been pushed into
|
|
251
|
-
// scrollback by chat output scrolling beneath it. Replaying that
|
|
252
|
-
// scrollback would paint duplicated footer rows, so emit only the
|
|
253
|
-
// viewport in this case.
|
|
254
|
-
if (session.stateDetector.hasTransientRenderFooter(session.terminal)) {
|
|
255
|
-
const viewportSnapshot = session.serializer.serialize({
|
|
256
|
-
scrollback: 0,
|
|
257
|
-
excludeAltBuffer: true,
|
|
258
|
-
});
|
|
259
|
-
return `${viewportSnapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
260
|
-
}
|
|
261
284
|
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
262
285
|
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
263
286
|
const rangeEnd = bufferLength - 1;
|
|
@@ -434,8 +457,13 @@ export class SessionManager extends EventEmitter {
|
|
|
434
457
|
setupDataHandler(session) {
|
|
435
458
|
// This handler always runs for all data
|
|
436
459
|
session.process.onData((data) => {
|
|
460
|
+
const beforeBaseY = session.terminal.buffer.normal.baseY;
|
|
461
|
+
if (this.hasCursorAddressedRedraw(data)) {
|
|
462
|
+
this.markCursorRedrawActive(session);
|
|
463
|
+
}
|
|
437
464
|
// Write data to virtual terminal
|
|
438
465
|
session.terminal.write(data);
|
|
466
|
+
this.handleScrollbackGrowthDuringRedraw(session, beforeBaseY);
|
|
439
467
|
if (this.shouldResetRestoreScrollback(data)) {
|
|
440
468
|
session.restoreScrollbackBaseLine =
|
|
441
469
|
session.terminal.buffer.normal.baseY;
|
|
@@ -678,6 +706,14 @@ export class SessionManager extends EventEmitter {
|
|
|
678
706
|
}
|
|
679
707
|
this.restoreDeferDeadlines.delete(sessionId);
|
|
680
708
|
}
|
|
709
|
+
clearCursorRedrawTracking(sessionId) {
|
|
710
|
+
this.cursorRedrawSessions.delete(sessionId);
|
|
711
|
+
const timer = this.cursorRedrawTimers.get(sessionId);
|
|
712
|
+
if (timer !== undefined) {
|
|
713
|
+
clearTimeout(timer);
|
|
714
|
+
this.cursorRedrawTimers.delete(sessionId);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
681
717
|
cancelAutoApproval(sessionId, reason = 'User input received') {
|
|
682
718
|
const session = this.sessions.get(sessionId);
|
|
683
719
|
if (!session) {
|
|
@@ -755,6 +791,7 @@ export class SessionManager extends EventEmitter {
|
|
|
755
791
|
clearTimeout(resizeTimer);
|
|
756
792
|
this.resizeSuppressTimers.delete(sessionId);
|
|
757
793
|
}
|
|
794
|
+
this.clearCursorRedrawTracking(sessionId);
|
|
758
795
|
this.cancelRestoreDefer(sessionId);
|
|
759
796
|
this.emit('sessionDestroyed', session);
|
|
760
797
|
}
|
|
@@ -815,6 +852,7 @@ export class SessionManager extends EventEmitter {
|
|
|
815
852
|
clearTimeout(resizeTimer);
|
|
816
853
|
this.resizeSuppressTimers.delete(sessionId);
|
|
817
854
|
}
|
|
855
|
+
this.clearCursorRedrawTracking(sessionId);
|
|
818
856
|
this.cancelRestoreDefer(sessionId);
|
|
819
857
|
this.emit('sessionDestroyed', session);
|
|
820
858
|
},
|
|
@@ -774,7 +774,7 @@ describe('SessionManager', () => {
|
|
|
774
774
|
});
|
|
775
775
|
});
|
|
776
776
|
describe('session restore snapshots', () => {
|
|
777
|
-
it('should emit a
|
|
777
|
+
it('should emit a normal-buffer restore snapshot from the restore baseline and restore the cursor position', async () => {
|
|
778
778
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
779
779
|
id: '1',
|
|
780
780
|
name: 'Main',
|
|
@@ -803,6 +803,32 @@ describe('SessionManager', () => {
|
|
|
803
803
|
});
|
|
804
804
|
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
|
|
805
805
|
});
|
|
806
|
+
it('should restore all retained normal-buffer scrollback when no baseline excludes it', 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
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
815
|
+
normalBuffer.baseY = 260;
|
|
816
|
+
normalBuffer.length = 300;
|
|
817
|
+
normalBuffer.cursorY = 7;
|
|
818
|
+
normalBuffer.cursorX = 11;
|
|
819
|
+
session.restoreScrollbackBaseLine = 0;
|
|
820
|
+
const serializeMock = vi
|
|
821
|
+
.spyOn(session.serializer, 'serialize')
|
|
822
|
+
.mockReturnValue('\u001b[31mrestored\u001b[0m');
|
|
823
|
+
sessionManager.setSessionActive(session.id, true);
|
|
824
|
+
expect(serializeMock).toHaveBeenCalledWith({
|
|
825
|
+
range: {
|
|
826
|
+
start: 0,
|
|
827
|
+
end: 299,
|
|
828
|
+
},
|
|
829
|
+
excludeAltBuffer: true,
|
|
830
|
+
});
|
|
831
|
+
});
|
|
806
832
|
it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
|
|
807
833
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
808
834
|
id: '1',
|
|
@@ -847,6 +873,69 @@ describe('SessionManager', () => {
|
|
|
847
873
|
mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
|
|
848
874
|
expect(session.restoreScrollbackBaseLine).toBe(17);
|
|
849
875
|
});
|
|
876
|
+
it('should keep full scrollback for normal output that scrolls', async () => {
|
|
877
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
878
|
+
id: '1',
|
|
879
|
+
name: 'Main',
|
|
880
|
+
command: 'claude',
|
|
881
|
+
});
|
|
882
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
883
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
884
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
885
|
+
normalBuffer.baseY = 10;
|
|
886
|
+
vi.mocked(session.terminal.write).mockImplementation(() => {
|
|
887
|
+
normalBuffer.baseY = 12;
|
|
888
|
+
});
|
|
889
|
+
mockPty.emit('data', 'ordinary output\n');
|
|
890
|
+
expect(session.restoreScrollbackBaseLine).toBe(0);
|
|
891
|
+
});
|
|
892
|
+
it('should skip scrollback that grows during cursor-addressed redraws', async () => {
|
|
893
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
894
|
+
id: '1',
|
|
895
|
+
name: 'Main',
|
|
896
|
+
command: 'claude',
|
|
897
|
+
});
|
|
898
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
899
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
900
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
901
|
+
normalBuffer.baseY = 10;
|
|
902
|
+
vi.mocked(session.terminal.write).mockImplementation(() => {
|
|
903
|
+
normalBuffer.baseY = 12;
|
|
904
|
+
});
|
|
905
|
+
mockPty.emit('data', '\x1b[Hredrawn status\n');
|
|
906
|
+
expect(session.restoreScrollbackBaseLine).toBe(12);
|
|
907
|
+
});
|
|
908
|
+
it('should keep cursor-addressed redraw tracking active for a short quiet window', async () => {
|
|
909
|
+
vi.useFakeTimers();
|
|
910
|
+
try {
|
|
911
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
912
|
+
id: '1',
|
|
913
|
+
name: 'Main',
|
|
914
|
+
command: 'claude',
|
|
915
|
+
});
|
|
916
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
917
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
918
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
919
|
+
normalBuffer.baseY = 10;
|
|
920
|
+
vi.mocked(session.terminal.write).mockImplementation(() => {
|
|
921
|
+
if (normalBuffer.baseY === 10) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
normalBuffer.baseY++;
|
|
925
|
+
});
|
|
926
|
+
mockPty.emit('data', '\x1b[Hredraw frame');
|
|
927
|
+
normalBuffer.baseY = 11;
|
|
928
|
+
mockPty.emit('data', 'plain continuation that scrolls');
|
|
929
|
+
expect(session.restoreScrollbackBaseLine).toBe(12);
|
|
930
|
+
vi.advanceTimersByTime(501);
|
|
931
|
+
normalBuffer.baseY = 20;
|
|
932
|
+
mockPty.emit('data', 'ordinary output after quiet window');
|
|
933
|
+
expect(session.restoreScrollbackBaseLine).toBe(12);
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
vi.useRealTimers();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
850
939
|
it('should flush live session data after the restore snapshot completes', async () => {
|
|
851
940
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
852
941
|
id: '1',
|
|
@@ -872,7 +961,7 @@ describe('SessionManager', () => {
|
|
|
872
961
|
sessionManager.setSessionActive(session.id, true);
|
|
873
962
|
expect(eventOrder).toEqual(['restore', 'data']);
|
|
874
963
|
});
|
|
875
|
-
it('should defer the
|
|
964
|
+
it('should defer the scrollback restore until PTY output is quiet when a transient footer is visible', async () => {
|
|
876
965
|
vi.useFakeTimers();
|
|
877
966
|
try {
|
|
878
967
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -899,10 +988,13 @@ describe('SessionManager', () => {
|
|
|
899
988
|
expect(serializeMock).not.toHaveBeenCalled();
|
|
900
989
|
vi.advanceTimersByTime(80);
|
|
901
990
|
expect(serializeMock).toHaveBeenCalledWith({
|
|
902
|
-
|
|
991
|
+
range: {
|
|
992
|
+
start: 120,
|
|
993
|
+
end: 299,
|
|
994
|
+
},
|
|
903
995
|
excludeAltBuffer: true,
|
|
904
996
|
});
|
|
905
|
-
expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
997
|
+
expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ scrollback: 0 }));
|
|
906
998
|
expect(restoreHandler).toHaveBeenCalledWith(session, '[31mviewport[0m[8;12H');
|
|
907
999
|
}
|
|
908
1000
|
finally {
|
|
@@ -29,6 +29,7 @@ export declare function extractBranchParts(branchName: string): {
|
|
|
29
29
|
prefix?: string;
|
|
30
30
|
name: string;
|
|
31
31
|
};
|
|
32
|
+
export declare function displaySuffix(session: Session, multipleForWorktree: boolean): string;
|
|
32
33
|
/**
|
|
33
34
|
* Prepares session items for display.
|
|
34
35
|
* Supports multiple sessions per worktree.
|
|
@@ -122,7 +122,7 @@ function indexSessionsByWorktree(sessions) {
|
|
|
122
122
|
}
|
|
123
123
|
return { byWorktreePath, maxAccessAt };
|
|
124
124
|
}
|
|
125
|
-
function displaySuffix(session, multipleForWorktree) {
|
|
125
|
+
export function displaySuffix(session, multipleForWorktree) {
|
|
126
126
|
if (multipleForWorktree) {
|
|
127
127
|
return session.sessionName
|
|
128
128
|
? `: ${session.sessionName}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.19",
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.19",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.19",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.19",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.19",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.19"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|