Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions apps/cli/src/ui/__tests__/uiStateStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useUIStateStore } from "../stores/uiStateStore.js"

describe("useUIStateStore", () => {
beforeEach(() => {
// Reset store to initial state before each test
useUIStateStore.getState().resetUIState()
})

describe("initialState", () => {
it("should have isCancelling set to false initially", () => {
const state = useUIStateStore.getState()
expect(state.isCancelling).toBe(false)
})

it("should have showExitHint set to false initially", () => {
const state = useUIStateStore.getState()
expect(state.showExitHint).toBe(false)
})

it("should have pendingExit set to false initially", () => {
const state = useUIStateStore.getState()
expect(state.pendingExit).toBe(false)
})

it("should have showTodoViewer set to false initially", () => {
const state = useUIStateStore.getState()
expect(state.showTodoViewer).toBe(false)
})
})

describe("setIsCancelling", () => {
it("should set isCancelling to true", () => {
useUIStateStore.getState().setIsCancelling(true)
expect(useUIStateStore.getState().isCancelling).toBe(true)
})

it("should set isCancelling to false", () => {
useUIStateStore.getState().setIsCancelling(true)
useUIStateStore.getState().setIsCancelling(false)
expect(useUIStateStore.getState().isCancelling).toBe(false)
})
})

describe("resetUIState", () => {
it("should reset isCancelling to false", () => {
useUIStateStore.getState().setIsCancelling(true)
useUIStateStore.getState().resetUIState()
expect(useUIStateStore.getState().isCancelling).toBe(false)
})

it("should reset all UI state to initial values", () => {
// Set some state first
const store = useUIStateStore.getState()
store.setShowExitHint(true)
store.setPendingExit(true)
store.setIsCancelling(true)
store.setCountdownSeconds(30)
store.setShowCustomInput(true)
store.setShowTodoViewer(true)

// Reset
useUIStateStore.getState().resetUIState()

// Verify all state is reset
const resetState = useUIStateStore.getState()
expect(resetState.showExitHint).toBe(false)
expect(resetState.pendingExit).toBe(false)
expect(resetState.isCancelling).toBe(false)
expect(resetState.countdownSeconds).toBeNull()
expect(resetState.showCustomInput).toBe(false)
expect(resetState.showTodoViewer).toBe(false)
})
})

describe("task cancellation flow", () => {
it("should allow setting isCancelling for the escape key spam protection", () => {
const store = useUIStateStore.getState

// Initially not cancelling
expect(store().isCancelling).toBe(false)

// First escape press sets cancelling to true
store().setIsCancelling(true)
expect(store().isCancelling).toBe(true)

// Subsequent escape presses should be ignored while isCancelling is true
// (this logic is in useGlobalInput, here we just verify the state holds)
expect(store().isCancelling).toBe(true)

// When task finishes cancelling (isLoading becomes false),
// isCancelling is reset
store().setIsCancelling(false)
expect(store().isCancelling).toBe(false)
})

it("should allow reading isCancelling synchronously during input handling", () => {
const store = useUIStateStore.getState

// Set the flag
store().setIsCancelling(true)

// Simulate synchronous read during input handling
const isCancelling = store().isCancelling
expect(isCancelling).toBe(true)

// The handler can use this to decide whether to send cancelTask
if (isCancelling) {
// Would show "Cancel in progress..." message
} else {
// Would send cancelTask and set isCancelling to true
}

// After task finishes, reset the flag
store().setIsCancelling(false)
expect(store().isCancelling).toBe(false)
})
})
})
27 changes: 26 additions & 1 deletion apps/cli/src/ui/hooks/useGlobalInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,16 @@ export function useGlobalInput({
setShowExitHint,
pendingExit,
setPendingExit,
isCancelling,
setIsCancelling,
} = useUIStateStore()

// Track Ctrl+C presses for "press again to exit" behavior
const exitHintTimeout = useRef<NodeJS.Timeout | null>(null)

// Track previous isLoading state for effect (using ref to properly scope to component instance)
const prevIsLoadingRef = useRef(false)

// Cleanup timeout on unmount
useEffect(() => {
return () => {
Expand All @@ -69,6 +74,15 @@ export function useGlobalInput({
}
}, [])

// Reset isCancelling when isLoading transitions from true to false
// This indicates the task has finished cancelling (either completed or ready to resume)
useEffect(() => {
if (prevIsLoadingRef.current && !isLoading && isCancelling) {
setIsCancelling(false)
}
prevIsLoadingRef.current = isLoading
}, [isLoading, isCancelling, setIsCancelling])

// Handle global keyboard shortcuts
useInput((input, key) => {
// Tab to toggle focus between scroll area and input (only when input is available)
Expand Down Expand Up @@ -132,7 +146,18 @@ export function useGlobalInput({
if (pickerIsOpen) {
return
}
// Send cancel message to extension (same as webview-ui Cancel button)

// Prevent multiple cancel requests from being sent while one is in progress
// This fixes a crash caused by spamming the escape key, which would send
// multiple cancelTask messages before the first one finished processing.
if (isCancelling) {
showInfo("Cancel in progress...", 1000)
return
}

// Set cancelling state and send cancel message to extension
setIsCancelling(true)
showInfo("Cancelling task...", 2000)
sendToExtension({ type: "cancelTask" })
return
}
Expand Down
9 changes: 9 additions & 0 deletions apps/cli/src/ui/stores/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ interface UIState {
showExitHint: boolean
pendingExit: boolean

// Task cancellation state - prevents duplicate cancel requests from crashing the CLI
// When true, the Escape key will be ignored until the current cancel operation completes
isCancelling: boolean

// Countdown timer for auto-accepting followup questions
countdownSeconds: number | null

Expand All @@ -33,6 +37,9 @@ interface UIActions {
setShowExitHint: (show: boolean) => void
setPendingExit: (pending: boolean) => void

// Task cancellation actions
setIsCancelling: (cancelling: boolean) => void

// Countdown timer actions
setCountdownSeconds: (seconds: number | null) => void

Expand All @@ -57,6 +64,7 @@ interface UIActions {
const initialState: UIState = {
showExitHint: false,
pendingExit: false,
isCancelling: false,
countdownSeconds: null,
showCustomInput: false,
isTransitioningToCustomInput: false,
Expand All @@ -77,6 +85,7 @@ export const useUIStateStore = create<UIState & UIActions>((set) => ({

setShowExitHint: (show) => set({ showExitHint: show }),
setPendingExit: (pending) => set({ pendingExit: pending }),
setIsCancelling: (cancelling) => set({ isCancelling: cancelling }),
setCountdownSeconds: (seconds) => set({ countdownSeconds: seconds }),
setShowCustomInput: (show) => set({ showCustomInput: show }),
setIsTransitioningToCustomInput: (transitioning) => set({ isTransitioningToCustomInput: transitioning }),
Expand Down
Loading