Skip to content

Commit 1718f0d

Browse files
committed
feat: add exit screen and navigation handling
- Introduced ExitScreen component for graceful application exit. - Updated navigation logic to handle 'exit' view. - Modified ShowTaskListWrapper to manage 'exit' state. - Enhanced useGlobalExit hook to navigate to exit screen on Ctrl+C. - Updated sensitive command management to support project and global scopes. - Added scope selection for sensitive command configuration. - Improved internationalization for exit messages and scope labels.
1 parent ae6b03b commit 1718f0d

11 files changed

Lines changed: 618 additions & 200 deletions

File tree

source/app.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const CustomHeadersScreen = React.lazy(
2020
() => import('./ui/pages/CustomHeadersScreen.js'),
2121
);
2222
const HelpScreen = React.lazy(() => import('./ui/pages/HelpScreen.js'));
23+
const ExitScreen = React.lazy(() => import('./ui/pages/ExitScreen.js'));
2324

2425
import {
2526
useGlobalExit,
@@ -45,7 +46,9 @@ type Props = {
4546

4647
// ShowTaskListWrapper: Handles task list mode with session conversion support
4748
function ShowTaskListWrapper() {
48-
const [currentView, setCurrentView] = useState<'tasks' | 'chat'>('tasks');
49+
const [currentView, setCurrentView] = useState<'tasks' | 'chat' | 'exit'>(
50+
'tasks',
51+
);
4952
const [chatScreenKey, setChatScreenKey] = useState(0);
5053
const [exitNotification, setExitNotification] =
5154
useState<ExitNotificationType>({
@@ -61,7 +64,29 @@ function ShowTaskListWrapper() {
6164
// Global exit handler
6265
useGlobalExit(setExitNotification);
6366

67+
// Listen for navigation events (including exit)
68+
useEffect(() => {
69+
const unsubscribe = onNavigate(event => {
70+
if (
71+
event.destination === 'exit' ||
72+
event.destination === 'tasks' ||
73+
event.destination === 'chat'
74+
) {
75+
setCurrentView(event.destination);
76+
}
77+
});
78+
return unsubscribe;
79+
}, []);
80+
6481
const renderView = () => {
82+
if (currentView === 'exit') {
83+
return (
84+
<Suspense fallback={loadingFallback}>
85+
<ExitScreen />
86+
</Suspense>
87+
);
88+
}
89+
6590
if (currentView === 'chat') {
6691
return (
6792
<Suspense fallback={loadingFallback}>
@@ -92,7 +117,7 @@ function ShowTaskListWrapper() {
92117
return (
93118
<Box flexDirection="column" width={terminalWidth}>
94119
{renderView()}
95-
{exitNotification.show && (
120+
{exitNotification.show && currentView !== 'exit' && (
96121
<Box paddingX={1} flexShrink={0}>
97122
<Alert variant="warning">{exitNotification.message}</Alert>
98123
</Box>
@@ -123,6 +148,7 @@ function AppContent({
123148
| 'systemprompt'
124149
| 'customheaders'
125150
| 'tasks'
151+
| 'exit'
126152
>(skipWelcome ? 'chat' : 'welcome');
127153

128154
// Add a key to force remount ChatScreen when returning from welcome screen
@@ -190,7 +216,7 @@ function AppContent({
190216
// Both 'chat' and 'resume-last' go to chat view
191217
setCurrentView(value === 'resume-last' ? 'chat' : value);
192218
} else if (value === 'exit') {
193-
gracefulExit();
219+
setCurrentView('exit');
194220
}
195221
};
196222

@@ -263,6 +289,12 @@ function AppContent({
263289
/>
264290
</Suspense>
265291
);
292+
case 'exit':
293+
return (
294+
<Suspense fallback={loadingFallback}>
295+
<ExitScreen version={version} />
296+
</Suspense>
297+
);
266298
default:
267299
return (
268300
<Suspense fallback={loadingFallback}>
@@ -280,7 +312,7 @@ function AppContent({
280312
return (
281313
<Box flexDirection="column" width={terminalWidth}>
282314
{renderView()}
283-
{exitNotification.show && (
315+
{exitNotification.show && currentView !== 'exit' && (
284316
<Box paddingX={1} flexShrink={0}>
285317
<Alert variant="warning">{exitNotification.message}</Alert>
286318
</Box>

source/hooks/conversation/chatLogic/useChatHandlers.ts

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {reviewAgent} from '../../../agents/reviewAgent.js';
77
import {sessionManager} from '../../../utils/session/sessionManager.js';
88
import {hashBasedSnapshotManager} from '../../../utils/codebase/hashBasedSnapshot.js';
99
import {convertSessionMessagesToUI} from '../../../utils/session/sessionConverter.js';
10-
import {vscodeConnection} from '../../../utils/ui/vscodeConnection.js';
1110
import {reindexCodebase} from '../../../utils/codebase/reindexCodebase.js';
11+
import {navigateTo} from '../../integration/useGlobalNavigation.js';
1212

1313
interface UseChatHandlersDeps {
1414
processMessage: (
@@ -43,7 +43,6 @@ export function useChatHandlers(
4343
setCodebaseProgress,
4444
setFileUpdateNotification,
4545
setWatcherEnabled,
46-
exitingApplicationText,
4746
setIsResumingSession,
4847
} = props;
4948
const {processMessage} = deps;
@@ -134,44 +133,7 @@ export function useChatHandlers(
134133
};
135134

136135
const handleQuit = async () => {
137-
setMessages(prev => [
138-
...prev,
139-
{
140-
role: 'command',
141-
content: exitingApplicationText,
142-
},
143-
]);
144-
145-
const quitTimeout = setTimeout(() => {
146-
process.exit(0);
147-
}, 3000);
148-
149-
try {
150-
if (codebaseAgentRef.current) {
151-
const agent = codebaseAgentRef.current;
152-
await Promise.race([
153-
(async () => {
154-
await agent.stop();
155-
agent.stopWatching();
156-
})(),
157-
new Promise(resolve => setTimeout(resolve, 2000)),
158-
]);
159-
}
160-
161-
if (
162-
vscodeConnection.isConnected() ||
163-
vscodeConnection.isClientRunning()
164-
) {
165-
vscodeConnection.stop();
166-
}
167-
168-
clearTimeout(quitTimeout);
169-
170-
process.exit(0);
171-
} catch (error) {
172-
clearTimeout(quitTimeout);
173-
process.exit(0);
174-
}
136+
navigateTo('exit');
175137
};
176138

177139
const handleReindexCodebase = async (force?: boolean) => {

source/hooks/integration/useGlobalExit.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {useInput} from 'ink';
22
import {useState} from 'react';
33
import {useI18n} from '../../i18n/index.js';
4+
import {navigateTo} from './useGlobalNavigation.js';
45

56
export interface ExitNotification {
67
show: boolean;
@@ -18,9 +19,7 @@ export function useGlobalExit(
1819
if (key.ctrl && input === 'c') {
1920
const now = Date.now();
2021
if (now - lastCtrlCTime < ctrlCTimeout) {
21-
// Second Ctrl+C within timeout - emit SIGINT to trigger cleanup
22-
// This ensures proper async cleanup before exit
23-
process.emit('SIGINT');
22+
navigateTo('exit');
2423
} else {
2524
// First Ctrl+C - show notification
2625
setLastCtrlCTime(now);

source/hooks/integration/useGlobalNavigation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export interface NavigationEvent {
1515
| 'settings'
1616
| 'systemprompt'
1717
| 'customheaders'
18-
| 'tasks';
18+
| 'tasks'
19+
| 'exit';
1920
}
2021

2122
// Emit navigation event

source/i18n/lang/en.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,20 @@ export const en: TranslationKeys = {
395395
enabled: 'Enabled',
396396
disabled: 'Disabled',
397397
customLabel: 'Custom',
398+
// Scope
399+
scopeProject: 'Project',
400+
scopeGlobal: 'Global',
401+
scopeSelectTitle: 'Select scope for new command',
402+
scopeSelectHint: '↑↓: Navigate • Enter: Select • Esc: Cancel',
403+
duplicatePattern:
404+
'Pattern "{pattern}" already exists in {scope} scope',
405+
resetScopeSelectTitle: 'Select scope to reset',
406+
resetGlobalDesc: 'Restore to default preset commands',
407+
resetProjectDesc: 'Clear all project custom commands',
408+
confirmResetScopeMessage:
409+
'⚠️ Press Enter again to confirm {scope} reset',
398410
// Add view
399-
addTitle: 'Add Custom Sensitive Command',
411+
addTitle: 'Add Custom Sensitive Command ({scope})',
400412
patternLabel: 'Pattern (supports wildcards, e.g., "rm*"):',
401413
patternPlaceholder: 'e.g., rm -rf, sudo, etc.',
402414
descriptionLabel: 'Description:',
@@ -1648,4 +1660,10 @@ export const en: TranslationKeys = {
16481660
errorPrefix: 'Error: ',
16491661
scrollHint: '↑↓ Scroll',
16501662
},
1663+
exitScreen: {
1664+
title: 'Goodbye',
1665+
goodbye: 'Thanks for using Snow CLI',
1666+
thankYou: 'See you next time',
1667+
version: 'v{version}',
1668+
},
16511669
};

source/i18n/lang/zh-TW.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,18 @@ export const zhTW: TranslationKeys = {
370370
enabled: '已啟用',
371371
disabled: '已停用',
372372
customLabel: '自訂',
373+
// Scope
374+
scopeProject: '專案',
375+
scopeGlobal: '全域',
376+
scopeSelectTitle: '選擇新命令的作用域',
377+
scopeSelectHint: '↑↓: 導航 • Enter: 選擇 • Esc: 取消',
378+
duplicatePattern: '模式 "{pattern}" 已存在於{scope}作用域',
379+
resetScopeSelectTitle: '選擇要重設的作用域',
380+
resetGlobalDesc: '還原為預設命令',
381+
resetProjectDesc: '清空所有專案自訂命令',
382+
confirmResetScopeMessage: '⚠️ 再次按 Enter 確認重設{scope}',
373383
// Add view
374-
addTitle: '新增自訂敏感命令',
384+
addTitle: '新增自訂敏感命令 ({scope})',
375385
patternLabel: '命令模式(支援萬用字元,例如 "rm*"):',
376386
patternPlaceholder: '例如: rm -rf, sudo 等',
377387
descriptionLabel: '描述:',
@@ -1560,4 +1570,10 @@ export const zhTW: TranslationKeys = {
15601570
errorPrefix: '錯誤:',
15611571
scrollHint: '↑↓ 捲動瀏覽',
15621572
},
1573+
exitScreen: {
1574+
title: '再見',
1575+
goodbye: '感謝使用 Snow CLI',
1576+
thankYou: '期待下次相見',
1577+
version: 'v{version}',
1578+
},
15631579
};

source/i18n/lang/zh.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,18 @@ export const zh: TranslationKeys = {
369369
enabled: '已启用',
370370
disabled: '已禁用',
371371
customLabel: '自定义',
372+
// Scope
373+
scopeProject: '项目',
374+
scopeGlobal: '全局',
375+
scopeSelectTitle: '选择新命令的作用域',
376+
scopeSelectHint: '↑↓: 导航 • Enter: 选择 • Esc: 取消',
377+
duplicatePattern: '模式 "{pattern}" 已存在于{scope}作用域',
378+
resetScopeSelectTitle: '选择要重置的作用域',
379+
resetGlobalDesc: '恢复为默认预设命令',
380+
resetProjectDesc: '清空所有项目自定义命令',
381+
confirmResetScopeMessage: '⚠️ 再次按 Enter 确认重置{scope}',
372382
// Add view
373-
addTitle: '添加自定义敏感命令',
383+
addTitle: '添加自定义敏感命令 ({scope})',
374384
patternLabel: '命令模式(支持通配符,例如 "rm*"):',
375385
patternPlaceholder: '例如: rm -rf, sudo 等',
376386
descriptionLabel: '描述:',
@@ -1564,4 +1574,10 @@ export const zh: TranslationKeys = {
15641574
errorPrefix: '错误:',
15651575
scrollHint: '↑↓ 滚动浏览',
15661576
},
1577+
exitScreen: {
1578+
title: '再见',
1579+
goodbye: '感谢使用 Snow CLI',
1580+
thankYou: '期待下次相见',
1581+
version: 'v{version}',
1582+
},
15671583
};

source/i18n/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,16 @@ export type TranslationKeys = {
365365
enabled: string;
366366
disabled: string;
367367
customLabel: string;
368+
// Scope
369+
scopeProject: string;
370+
scopeGlobal: string;
371+
scopeSelectTitle: string;
372+
scopeSelectHint: string;
373+
duplicatePattern: string;
374+
resetScopeSelectTitle: string;
375+
resetGlobalDesc: string;
376+
resetProjectDesc: string;
377+
confirmResetScopeMessage: string;
368378
// Add view
369379
addTitle: string;
370380
patternLabel: string;
@@ -1545,6 +1555,12 @@ export type TranslationKeys = {
15451555
errorPrefix: string;
15461556
scrollHint: string;
15471557
};
1558+
exitScreen: {
1559+
title: string;
1560+
goodbye: string;
1561+
thankYou: string;
1562+
version: string;
1563+
};
15481564
};
15491565

15501566
import type {Language as Lang} from '../utils/config/languageConfig.js';

source/ui/pages/ExitScreen.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React, {useEffect, useMemo} from 'react';
2+
import {Box, Text} from 'ink';
3+
import Gradient from 'ink-gradient';
4+
import {useI18n} from '../../i18n/index.js';
5+
import {useTheme} from '../contexts/ThemeContext.js';
6+
import {useTerminalSize} from '../../hooks/ui/useTerminalSize.js';
7+
import {gracefulExit} from '../../utils/core/processManager.js';
8+
9+
type Props = {
10+
version?: string;
11+
};
12+
13+
function dotLine(width: number): string {
14+
const count = Math.max(0, Math.floor(width / 3));
15+
return Array.from({length: count}, () => '·').join(' ');
16+
}
17+
18+
export default function ExitScreen({version = '1.0.0'}: Props) {
19+
const {t} = useI18n();
20+
const {theme} = useTheme();
21+
const {columns: terminalWidth} = useTerminalSize();
22+
23+
useEffect(() => {
24+
gracefulExit();
25+
}, []);
26+
27+
const versionText = t.exitScreen.version.replace('{version}', version);
28+
const dotWidth = Math.max(12, Math.min(terminalWidth - 8, 42));
29+
const dots = useMemo(() => dotLine(dotWidth), [dotWidth]);
30+
const colors = theme.colors;
31+
32+
return (
33+
<Box
34+
flexDirection="column"
35+
alignItems="center"
36+
justifyContent="center"
37+
paddingY={1}
38+
width={terminalWidth}
39+
>
40+
<Box flexDirection="column" alignItems="center">
41+
<Text color={colors.border} dimColor>
42+
{dots}
43+
</Text>
44+
45+
<Box marginTop={1}>
46+
<Text>
47+
<Text color={colors.cyan}></Text>
48+
<Gradient colors={colors.logoGradient}>SNOW CLI</Gradient>
49+
</Text>
50+
</Box>
51+
52+
<Box marginTop={1}>
53+
<Text color={colors.border} dimColor>
54+
{'── '}
55+
</Text>
56+
<Text color={colors.menuInfo} bold>
57+
{t.exitScreen.title}
58+
</Text>
59+
<Text color={colors.border} dimColor>
60+
{' ──'}
61+
</Text>
62+
</Box>
63+
64+
<Box marginTop={1}>
65+
<Text color={colors.text}>{t.exitScreen.goodbye}</Text>
66+
</Box>
67+
68+
<Text color={colors.menuSecondary}>{t.exitScreen.thankYou}</Text>
69+
70+
<Box marginTop={1}>
71+
<Text color={colors.border} dimColor>
72+
{dots}
73+
</Text>
74+
</Box>
75+
76+
<Text color={colors.menuSecondary} dimColor>
77+
{versionText}
78+
</Text>
79+
</Box>
80+
</Box>
81+
);
82+
}

0 commit comments

Comments
 (0)