Skip to content

Commit ff18564

Browse files
Merge pull request #73 from ai-action/feat/session
2 parents cf5ada3 + fcb8b9b commit ff18564

5 files changed

Lines changed: 208 additions & 35 deletions

File tree

src/components/SessionManager.test.tsx

Lines changed: 145 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { Text } from 'ink';
1+
import { Text, useStdout } from 'ink';
22
import { render } from 'ink-testing-library';
33
import { useState } from 'react';
44

55
import { SessionManager } from './SessionManager';
66

7-
const selectionState = vi.hoisted(() => ({
8-
instanceId: '',
9-
mountCount: 0,
10-
onCancel: null as (() => void) | null,
11-
onChange: null as ((value: string) => void) | null,
12-
options: [] as { label: string; value: string }[],
7+
const { mockColumns, selectionState } = vi.hoisted(() => ({
8+
mockColumns: {
9+
value: 100,
10+
},
11+
selectionState: {
12+
instanceId: '',
13+
mountCount: 0,
14+
onCancel: null as (() => void) | null,
15+
onChange: null as ((value: string) => void) | null,
16+
options: [] as { label: string; value: string }[],
17+
},
1318
}));
1419

1520
const sessions = vi.hoisted(() => [
@@ -29,6 +34,15 @@ const sessions = vi.hoisted(() => [
2934
},
3035
]);
3136

37+
vi.mock('ink', async () => ({
38+
...(await vi.importActual('ink')),
39+
useStdout: vi.fn(() => ({
40+
stdout: {
41+
columns: mockColumns.value,
42+
},
43+
})),
44+
}));
45+
3246
vi.mock('./SelectPrompt', () => ({
3347
SelectPrompt: ({
3448
onCancel,
@@ -69,6 +83,8 @@ vi.mock('../utils/session', () => ({
6983

7084
describe('SessionManager', () => {
7185
beforeEach(() => {
86+
mockColumns.value = 100;
87+
vi.mocked(useStdout).mockClear();
7288
selectionState.instanceId = '';
7389
selectionState.mountCount = 0;
7490
selectionState.onCancel = null;
@@ -94,7 +110,53 @@ describe('SessionManager', () => {
94110
);
95111
});
96112

97-
it('renders the current session, other sessions, and management actions', () => {
113+
it('truncates long session labels to the available width', () => {
114+
mockColumns.value = 36;
115+
sessions[1] = {
116+
...sessions[1],
117+
title:
118+
'testing a really long input with a lot of words, writing stuff just to fill space',
119+
};
120+
121+
const sessionManager = (
122+
<SessionManager
123+
currentSessionId="session-1"
124+
onClose={vi.fn()}
125+
onDelete={vi.fn()}
126+
onNew={vi.fn()}
127+
onOpen={vi.fn()}
128+
/>
129+
);
130+
const { lastFrame, rerender } = render(sessionManager);
131+
132+
selectionState.onChange?.('open-menu');
133+
rerender(sessionManager);
134+
135+
expect(lastFrame()).toContain('…');
136+
expect(lastFrame()).not.toContain('fill space');
137+
});
138+
139+
it('handles extremely narrow terminal by truncating entire label', () => {
140+
mockColumns.value = 5;
141+
142+
const sessionManager = (
143+
<SessionManager
144+
currentSessionId="session-1"
145+
onClose={vi.fn()}
146+
onDelete={vi.fn()}
147+
onNew={vi.fn()}
148+
onOpen={vi.fn()}
149+
/>
150+
);
151+
const { lastFrame, rerender } = render(sessionManager);
152+
153+
selectionState.onChange?.('open-menu');
154+
rerender(sessionManager);
155+
156+
expect(lastFrame()).toContain('…');
157+
});
158+
159+
it('renders the main session actions', () => {
98160
const { lastFrame } = render(
99161
<SessionManager
100162
currentSessionId="session-1"
@@ -107,9 +169,10 @@ describe('SessionManager', () => {
107169

108170
expect(lastFrame()).toContain('Sessions');
109171
expect(lastFrame()).toContain('Select session');
110-
expect(lastFrame()).toContain('Current: First session');
111-
expect(lastFrame()).toContain('Second session');
172+
expect(lastFrame()).toContain('Open session');
112173
expect(lastFrame()).toContain('Delete session');
174+
expect(lastFrame()).not.toContain('Current: First session');
175+
expect(lastFrame()).not.toContain('Second session');
113176
});
114177

115178
it('shows an error when onOpen throws', () => {
@@ -127,6 +190,8 @@ describe('SessionManager', () => {
127190
);
128191
const { lastFrame, rerender } = render(sessionManager);
129192

193+
selectionState.onChange?.('open-menu');
194+
rerender(sessionManager);
130195
selectionState.onChange?.('open:session-2');
131196
rerender(sessionManager);
132197

@@ -172,6 +237,8 @@ describe('SessionManager', () => {
172237
);
173238
const { lastFrame, rerender } = render(sessionManager);
174239

240+
selectionState.onChange?.('open-menu');
241+
rerender(sessionManager);
175242
selectionState.onChange?.('open:session-2');
176243
rerender(sessionManager);
177244

@@ -204,16 +271,19 @@ describe('SessionManager', () => {
204271

205272
it('opens the selected session', () => {
206273
const onOpen = vi.fn();
207-
render(
274+
const sessionManager = (
208275
<SessionManager
209276
currentSessionId="session-1"
210277
onClose={vi.fn()}
211278
onDelete={vi.fn()}
212279
onNew={vi.fn()}
213280
onOpen={onOpen}
214-
/>,
281+
/>
215282
);
283+
const { rerender } = render(sessionManager);
216284

285+
selectionState.onChange?.('open-menu');
286+
rerender(sessionManager);
217287
selectionState.onChange?.('open:session-2');
218288

219289
expect(onOpen).toHaveBeenCalledWith('session-2');
@@ -254,7 +324,7 @@ describe('SessionManager', () => {
254324
expect(onClose).toHaveBeenCalledTimes(2);
255325
});
256326

257-
it('includes the delete-menu option', () => {
327+
it('includes the open-menu and delete-menu options', () => {
258328
render(
259329
<SessionManager
260330
currentSessionId="session-1"
@@ -265,11 +335,41 @@ describe('SessionManager', () => {
265335
/>,
266336
);
267337

338+
expect(selectionState.options.map(({ value }) => value)).toContain(
339+
'open-menu',
340+
);
268341
expect(selectionState.options.map(({ value }) => value)).toContain(
269342
'delete-menu',
270343
);
271344
});
272345

346+
it('shows sessions in open mode', () => {
347+
const sessionManager = (
348+
<SessionManager
349+
currentSessionId="session-1"
350+
onClose={vi.fn()}
351+
onDelete={vi.fn()}
352+
onNew={vi.fn()}
353+
onOpen={vi.fn()}
354+
/>
355+
);
356+
const { lastFrame, rerender } = render(sessionManager);
357+
358+
selectionState.onChange?.('open-menu');
359+
rerender(sessionManager);
360+
361+
expect(lastFrame()).toContain('Open session');
362+
expect(lastFrame()).toContain('Second session');
363+
expect(lastFrame()).not.toContain('Current: First session');
364+
expect(selectionState.options.map(({ value }) => value)).toContain(
365+
'open:session-2',
366+
);
367+
expect(selectionState.options.map(({ value }) => value)).not.toContain(
368+
'open:session-1',
369+
);
370+
expect(selectionState.options.map(({ value }) => value)).toContain('back');
371+
});
372+
273373
it('deletes the selected session in delete mode', () => {
274374
const onDelete = vi.fn();
275375
render(
@@ -343,7 +443,30 @@ describe('SessionManager', () => {
343443
);
344444
});
345445

346-
it('remounts the select prompt when switching between delete and main views', () => {
446+
it('returns to main view when back is selected in open mode', () => {
447+
const sessionManager = (
448+
<SessionManager
449+
currentSessionId="session-1"
450+
onClose={vi.fn()}
451+
onDelete={vi.fn()}
452+
onNew={vi.fn()}
453+
onOpen={vi.fn()}
454+
/>
455+
);
456+
const { rerender } = render(sessionManager);
457+
458+
selectionState.onChange?.('open-menu');
459+
rerender(sessionManager);
460+
expect(selectionState.options.map(({ value }) => value)).toContain('back');
461+
462+
selectionState.onChange?.('back');
463+
rerender(sessionManager);
464+
expect(selectionState.options.map(({ value }) => value)).toContain(
465+
'open-menu',
466+
);
467+
});
468+
469+
it('remounts the select prompt when switching between main, open, and delete views', () => {
347470
const sessionManager = (
348471
<SessionManager
349472
currentSessionId="session-1"
@@ -357,14 +480,20 @@ describe('SessionManager', () => {
357480

358481
const mainInstanceId = selectionState.instanceId;
359482

360-
selectionState.onChange?.('delete-menu');
483+
selectionState.onChange?.('open-menu');
361484
rerender(sessionManager);
362-
const deleteInstanceId = selectionState.instanceId;
485+
const openInstanceId = selectionState.instanceId;
363486

364487
selectionState.onChange?.('back');
365488
rerender(sessionManager);
366489
const nextMainInstanceId = selectionState.instanceId;
367490

491+
selectionState.onChange?.('delete-menu');
492+
rerender(sessionManager);
493+
const deleteInstanceId = selectionState.instanceId;
494+
495+
expect(openInstanceId).not.toBe(mainInstanceId);
496+
expect(nextMainInstanceId).not.toBe(openInstanceId);
368497
expect(deleteInstanceId).not.toBe(mainInstanceId);
369498
expect(nextMainInstanceId).not.toBe(deleteInstanceId);
370499
});

src/components/SessionManager.tsx

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Box, Text } from 'ink';
1+
import { Box, Text, useStdout } from 'ink';
22
import { useCallback, useState } from 'react';
33

4+
import { UI } from '../constants';
45
import { listSessions, type SessionMetadata } from '../utils/session';
56
import { SelectPrompt, SelectPromptHint } from './SelectPrompt';
67

@@ -14,6 +15,7 @@ interface Props {
1415

1516
enum VIEW {
1617
MAIN = 'main',
18+
OPEN = 'open',
1719
DELETE = 'delete',
1820
}
1921

@@ -23,12 +25,32 @@ const ACTION = {
2325
DELETE_MENU: 'delete-menu',
2426
DELETE_PREFIX: 'delete:',
2527
NEW: 'new',
28+
OPEN_MENU: 'open-menu',
2629
OPEN_PREFIX: 'open:',
2730
} as const;
2831

29-
function formatSessionLabel(session: SessionMetadata): string {
32+
const SESSION_LABEL_PADDING = 4;
33+
34+
function truncate(value: string, maxLength: number): string {
35+
return value.length > maxLength
36+
? `${value.slice(0, maxLength - 1).trimEnd()}${UI.ELLIPSIS}`
37+
: value;
38+
}
39+
40+
function formatSessionLabel(
41+
session: SessionMetadata,
42+
maxWidth: number,
43+
prefix = '',
44+
): string {
3045
const timestamp = new Date(session.updatedAt).toLocaleString();
31-
return `${session.title} (${timestamp})`;
46+
const suffix = ` (${timestamp})`;
47+
const availableTitleWidth = maxWidth - prefix.length - suffix.length;
48+
49+
if (availableTitleWidth < 1) {
50+
return truncate(`${prefix}${session.title}${suffix}`, maxWidth);
51+
}
52+
53+
return `${prefix}${truncate(session.title, availableTitleWidth)}${suffix}`;
3254
}
3355

3456
export function SessionManager({
@@ -41,28 +63,37 @@ export function SessionManager({
4163
const [view, setView] = useState<VIEW>(VIEW.MAIN);
4264
const [error, setError] = useState<string>();
4365
const [, refreshSessionList] = useState(0);
66+
const { stdout } = useStdout();
4467

4568
const sessions = listSessions();
69+
const maxLabelWidth = Math.max(1, stdout.columns - SESSION_LABEL_PADDING);
4670
const options =
47-
view === VIEW.DELETE
71+
view === VIEW.OPEN
4872
? [
4973
...sessions
5074
.filter(({ id }) => id !== currentSessionId)
5175
.map((session) => ({
52-
label: `Delete ${formatSessionLabel(session)}`,
53-
value: `${ACTION.DELETE_PREFIX}${session.id}`,
76+
label: formatSessionLabel(session, maxLabelWidth),
77+
value: `${ACTION.OPEN_PREFIX}${session.id}`,
5478
})),
5579
{ label: 'Back', value: ACTION.BACK },
5680
]
57-
: [
58-
{ label: 'New session', value: ACTION.NEW },
59-
...sessions.map((session) => ({
60-
label: `${session.id === currentSessionId ? 'Current: ' : ''}${formatSessionLabel(session)}`,
61-
value: `${ACTION.OPEN_PREFIX}${session.id}`,
62-
})),
63-
{ label: 'Delete session', value: ACTION.DELETE_MENU },
64-
{ label: 'Close', value: ACTION.CLOSE },
65-
];
81+
: view === VIEW.DELETE
82+
? [
83+
...sessions
84+
.filter(({ id }) => id !== currentSessionId)
85+
.map((session) => ({
86+
label: formatSessionLabel(session, maxLabelWidth, 'Delete '),
87+
value: `${ACTION.DELETE_PREFIX}${session.id}`,
88+
})),
89+
{ label: 'Back', value: ACTION.BACK },
90+
]
91+
: [
92+
{ label: 'New session', value: ACTION.NEW },
93+
{ label: 'Open session', value: ACTION.OPEN_MENU },
94+
{ label: 'Delete session', value: ACTION.DELETE_MENU },
95+
{ label: 'Close', value: ACTION.CLOSE },
96+
];
6697

6798
const handleChange = useCallback(
6899
(value: string) => {
@@ -79,6 +110,10 @@ export function SessionManager({
79110
setView(VIEW.DELETE);
80111
break;
81112

113+
case value === ACTION.OPEN_MENU:
114+
setView(VIEW.OPEN);
115+
break;
116+
82117
case value === ACTION.BACK:
83118
setView(VIEW.MAIN);
84119
break;
@@ -118,7 +153,13 @@ export function SessionManager({
118153
<Box flexDirection="column">
119154
<Text>Sessions</Text>
120155
<SelectPromptHint
121-
message={view === VIEW.DELETE ? 'Delete session' : 'Select session'}
156+
message={
157+
view === VIEW.DELETE
158+
? 'Delete session'
159+
: view === VIEW.OPEN
160+
? 'Open session'
161+
: 'Select session'
162+
}
122163
/>
123164

124165
{error && (

src/constants/ui.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export const PROMPT_PREFIX = '> ';
33
export const AGENT_MARGIN_X = 2;
44
export const MARKDOWN_HR_CHARACTER = '─';
55
export const DIAMOND = '❖';
6+
export const ELLIPSIS = '…';

0 commit comments

Comments
 (0)