Skip to content

Commit 895ebdb

Browse files
committed
fix(cli): handle print abort and completion loading
1 parent e26d8d9 commit 895ebdb

10 files changed

Lines changed: 230 additions & 14 deletions

File tree

apps/cli/src/app.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
writeToStderr,
2424
writeToStdout,
2525
} from '#cli-utils/stdio'
26+
import { isPrintModeSignalAbortHandlingActive } from '#host-cli/entrypoints/cli/print/signalState'
2627

2728
import { cursorShow } from 'ansi-escapes'
2829
import { openSync } from 'fs'
@@ -204,10 +205,15 @@ async function gracefulExit(code = 0) {
204205
process.exit(code)
205206
}
206207

207-
process.on('SIGINT', () => void gracefulExit(0))
208-
process.on('SIGTERM', () => void gracefulExit(0))
208+
function handleProcessSignalExit(code = 0): void {
209+
if (isPrintModeSignalAbortHandlingActive()) return
210+
void gracefulExit(code)
211+
}
212+
213+
process.on('SIGINT', () => handleProcessSignalExit(0))
214+
process.on('SIGTERM', () => handleProcessSignalExit(0))
209215
// Windows CTRL+BREAK
210-
process.on('SIGBREAK', () => void gracefulExit(0))
216+
process.on('SIGBREAK', () => handleProcessSignalExit(0))
211217
process.on('unhandledRejection', err => {
212218
logError(err)
213219
void gracefulExit(1)

apps/cli/src/entrypoints/cli/print/runSingleTurn.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { QueryToolUseContext } from '#core/engine'
44
import { MaxBudgetUsdExceededError } from '#core/errors/maxBudgetUsd'
55
import { MaxTurnsExceededError } from '#core/errors/maxTurns'
66
import { randomUUID } from 'crypto'
7+
import { beginPrintModeSignalAbortHandling } from './signalState'
78

89
const PRINT_MODE_ABORT_SIGNALS: NodeJS.Signals[] = [
910
'SIGINT',
@@ -14,6 +15,7 @@ const PRINT_MODE_ABORT_SIGNALS: NodeJS.Signals[] = [
1415
function installPrintModeSignalAbort(
1516
abortController: AbortController,
1617
): () => void {
18+
const endPrintModeSignalAbortHandling = beginPrintModeSignalAbortHandling()
1719
const abort = () => {
1820
abortController.abort()
1921
}
@@ -26,6 +28,7 @@ function installPrintModeSignalAbort(
2628
for (const signal of PRINT_MODE_ABORT_SIGNALS) {
2729
process.removeListener(signal, abort)
2830
}
31+
endPrintModeSignalAbortHandling()
2932
}
3033
}
3134

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
let activePrintSignalAbortHandlers = 0
2+
3+
export function beginPrintModeSignalAbortHandling(): () => void {
4+
activePrintSignalAbortHandlers += 1
5+
let disposed = false
6+
7+
return () => {
8+
if (disposed) return
9+
disposed = true
10+
activePrintSignalAbortHandlers = Math.max(
11+
0,
12+
activePrintSignalAbortHandlers - 1,
13+
)
14+
}
15+
}
16+
17+
export function isPrintModeSignalAbortHandlingActive(): boolean {
18+
return activePrintSignalAbortHandlers > 0
19+
}

apps/cli/src/ui/hooks/useUnifiedCompletion/actions.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useCallback } from 'react'
22

3-
import type {
4-
CompletionContext,
5-
UnifiedSuggestion,
3+
import {
4+
isLoadingSuggestion,
5+
type CompletionContext,
6+
type UnifiedSuggestion,
67
} from '#cli-utils/completion/types'
78

89
export function useCompletionActions(args: {
@@ -18,6 +19,8 @@ export function useCompletionActions(args: {
1819
} {
1920
const completeWith = useCallback(
2021
(suggestion: UnifiedSuggestion, context: CompletionContext) => {
22+
if (isLoadingSuggestion(suggestion)) return
23+
2124
let completion: string
2225

2326
if (context.type === 'command') {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { __computeCompletionRefreshForTests } from './hook'
4+
import type { CompletionState } from './types'
5+
6+
function makeState(overrides: Partial<CompletionState>): CompletionState {
7+
return {
8+
suggestions: [],
9+
selectedIndex: 0,
10+
isActive: false,
11+
context: null,
12+
preview: null,
13+
emptyDirMessage: '',
14+
suppressUntil: 0,
15+
...overrides,
16+
}
17+
}
18+
19+
describe('__computeCompletionRefreshForTests', () => {
20+
test('refreshes active loading suggestions when new command matches arrive', () => {
21+
const state = makeState({
22+
isActive: true,
23+
context: {
24+
type: 'file',
25+
prefix: 'kub',
26+
startPos: 0,
27+
endPos: 3,
28+
},
29+
suggestions: [
30+
{
31+
value: 'loading...',
32+
displayValue: 'Loading system commands...',
33+
type: 'file',
34+
score: 0,
35+
metadata: { isLoading: true },
36+
},
37+
],
38+
})
39+
40+
const result = __computeCompletionRefreshForTests({
41+
isEnabled: true,
42+
state,
43+
suggestions: [
44+
{
45+
value: 'kubectl',
46+
displayValue: '$ kubectl',
47+
type: 'command',
48+
score: 100,
49+
},
50+
],
51+
})
52+
53+
expect(result.action).toBe('refresh')
54+
if (result.action !== 'refresh') return
55+
expect(result.suggestions[0]?.value).toBe('kubectl')
56+
expect(result.selectedIndex).toBe(0)
57+
})
58+
59+
test('does not refresh while a completion preview is active', () => {
60+
const result = __computeCompletionRefreshForTests({
61+
isEnabled: true,
62+
state: makeState({
63+
isActive: true,
64+
context: {
65+
type: 'file',
66+
prefix: 'gi',
67+
startPos: 0,
68+
endPos: 2,
69+
},
70+
preview: {
71+
isActive: true,
72+
originalInput: 'gi',
73+
wordRange: [0, 3],
74+
},
75+
}),
76+
suggestions: [
77+
{
78+
value: 'git',
79+
displayValue: '$ git',
80+
type: 'command',
81+
score: 100,
82+
},
83+
],
84+
})
85+
86+
expect(result.action).toBe('none')
87+
})
88+
})

apps/cli/src/ui/hooks/useUnifiedCompletion/hook.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,34 @@ export function __getCompletionContextForTests(args: {
2525
return getCompletionContext(args)
2626
}
2727

28+
export function __computeCompletionRefreshForTests(args: {
29+
isEnabled: boolean
30+
state: CompletionState
31+
suggestions: UnifiedSuggestion[]
32+
}):
33+
| { action: 'none' }
34+
| { action: 'reset' }
35+
| {
36+
action: 'refresh'
37+
suggestions: UnifiedSuggestion[]
38+
selectedIndex: number
39+
} {
40+
if (!args.isEnabled || !args.state.isActive || !args.state.context) {
41+
return { action: 'none' }
42+
}
43+
if (args.state.preview?.isActive) return { action: 'none' }
44+
if (args.suggestions.length === 0) return { action: 'reset' }
45+
46+
return {
47+
action: 'refresh',
48+
suggestions: args.suggestions,
49+
selectedIndex: Math.min(
50+
args.state.selectedIndex,
51+
args.suggestions.length - 1,
52+
),
53+
}
54+
}
55+
2856
export function useUnifiedCompletion({
2957
input,
3058
cursorOffset,
@@ -110,6 +138,38 @@ export function useUnifiedCompletion({
110138
}
111139
}, [isEnabled, resetCompletion, state.isActive])
112140

141+
useEffect(() => {
142+
if (!state.context) return
143+
144+
const nextSuggestions = generateSuggestions(state.context)
145+
const result = __computeCompletionRefreshForTests({
146+
isEnabled,
147+
state,
148+
suggestions: nextSuggestions,
149+
})
150+
151+
if (result.action === 'reset') {
152+
resetCompletion()
153+
return
154+
}
155+
156+
if (result.action === 'refresh') {
157+
setState(prev => ({
158+
...prev,
159+
suggestions: result.suggestions,
160+
selectedIndex: result.selectedIndex,
161+
}))
162+
}
163+
}, [
164+
generateSuggestions,
165+
isEnabled,
166+
resetCompletion,
167+
state.context,
168+
state.isActive,
169+
state.selectedIndex,
170+
state.preview?.isActive,
171+
])
172+
113173
useUnifiedCompletionTabKey({
114174
input,
115175
state,

apps/cli/src/ui/hooks/useUnifiedCompletion/useNavigationKeys.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useKeypress } from '#ui-ink/hooks/useKeypress'
22
import { KEYPRESS_PRIORITY } from '#ui-ink/constants/keypressPriority'
33

4-
import type {
5-
CompletionContext,
6-
UnifiedSuggestion,
4+
import {
5+
isLoadingSuggestion,
6+
type CompletionContext,
7+
type UnifiedSuggestion,
78
} from '#cli-utils/completion/types'
89
import type { CompletionState } from './types'
910

@@ -63,6 +64,10 @@ export function useUnifiedCompletionNavigationKeys(args: {
6364
args.state.context
6465
) {
6566
const context = args.state.context
67+
const selectedSuggestion =
68+
args.state.suggestions[args.state.selectedIndex]
69+
if (isLoadingSuggestion(selectedSuggestion)) return true
70+
6671
const isFileCompletion = context.type === 'file'
6772

6873
if (isFileCompletion) {
@@ -76,8 +81,6 @@ export function useUnifiedCompletionNavigationKeys(args: {
7681
return false
7782
} else {
7883
// Command/agent/other completion: fill the selected suggestion
79-
const selectedSuggestion =
80-
args.state.suggestions[args.state.selectedIndex]
8184
args.completeWith(selectedSuggestion, context)
8285
args.resetCompletion()
8386
return true
@@ -94,6 +97,10 @@ export function useUnifiedCompletionNavigationKeys(args: {
9497
}
9598

9699
const suggestion = args.state.suggestions[newIndex]
100+
if (isLoadingSuggestion(suggestion)) {
101+
args.updateState({ selectedIndex: newIndex })
102+
return
103+
}
97104
const previewValue = getPreviewText(suggestion, args.state.context)
98105

99106
if (args.state.preview?.isActive && args.state.context) {
@@ -151,6 +158,8 @@ export function useUnifiedCompletionNavigationKeys(args: {
151158
if (key.rightArrow) {
152159
const selectedSuggestion =
153160
args.state.suggestions[args.state.selectedIndex]
161+
if (isLoadingSuggestion(selectedSuggestion)) return true
162+
154163
const isDirectory = selectedSuggestion.value.endsWith('/')
155164

156165
if (!args.state.context) return false

apps/cli/src/ui/hooks/useUnifiedCompletion/useTabKey.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useKeypress, type Key } from '#ui-ink/hooks/useKeypress'
22
import { KEYPRESS_PRIORITY } from '#ui-ink/constants/keypressPriority'
33

4-
import type {
5-
CompletionContext,
6-
UnifiedSuggestion,
4+
import {
5+
isLoadingSuggestion,
6+
type CompletionContext,
7+
type UnifiedSuggestion,
78
} from '#cli-utils/completion/types'
89
import type { CompletionState } from './types'
910

@@ -43,6 +44,7 @@ export function useUnifiedCompletionTabKey(args: {
4344
const nextIndex =
4445
(args.state.selectedIndex + 1) % args.state.suggestions.length
4546
const nextSuggestion = args.state.suggestions[nextIndex]
47+
if (isLoadingSuggestion(nextSuggestion)) return true
4648

4749
if (args.state.context) {
4850
const currentWord = args.input.slice(args.state.context.startPos)
@@ -95,13 +97,18 @@ export function useUnifiedCompletionTabKey(args: {
9597
return false
9698
}
9799
if (currentSuggestions.length === 1) {
100+
if (isLoadingSuggestion(currentSuggestions[0])) {
101+
args.activateCompletion(currentSuggestions, context)
102+
return true
103+
}
98104
args.completeWith(currentSuggestions[0], context)
99105
return true
100106
}
101107

102108
args.activateCompletion(currentSuggestions, context)
103109

104110
const firstSuggestion = currentSuggestions[0]
111+
if (isLoadingSuggestion(firstSuggestion)) return true
105112
const currentWord = args.input.slice(context.startPos)
106113
const wordEnd = currentWord.search(/\s/)
107114
const actualEndPos =

apps/cli/src/utils/completion/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ export interface CompletionContext {
1717
endPos: number
1818
trigger?: '@' | '/' | null
1919
}
20+
21+
export function isLoadingSuggestion(
22+
suggestion: UnifiedSuggestion | undefined,
23+
): boolean {
24+
return Boolean(suggestion?.metadata?.isLoading)
25+
}

packages/core/src/test/unit/print-mode-signal-abort.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from 'bun:test'
22
import { __installPrintModeSignalAbortForTests } from '#host-cli/entrypoints/cli/print/runSingleTurn'
3+
import { isPrintModeSignalAbortHandlingActive } from '#host-cli/entrypoints/cli/print/signalState'
34

45
describe('print mode signal cancellation', () => {
56
test('SIGINT aborts the active print turn controller', () => {
@@ -27,4 +28,18 @@ describe('print mode signal cancellation', () => {
2728

2829
expect(controller.signal.aborted).toBe(false)
2930
})
31+
32+
test('tracks active print signal handling for global handler deferral', () => {
33+
const controller = new AbortController()
34+
expect(isPrintModeSignalAbortHandlingActive()).toBe(false)
35+
36+
const cleanup = __installPrintModeSignalAbortForTests(controller)
37+
try {
38+
expect(isPrintModeSignalAbortHandlingActive()).toBe(true)
39+
} finally {
40+
cleanup()
41+
}
42+
43+
expect(isPrintModeSignalAbortHandlingActive()).toBe(false)
44+
})
3045
})

0 commit comments

Comments
 (0)