Skip to content
Merged
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
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@orama/orama": "^3.1.18",
"@orama/plugin-data-persistence": "^3.1.18",

"@vscode/ripgrep": "^1.17.1",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
Expand Down Expand Up @@ -137,9 +138,11 @@
"zod": "3.25.76"
},
"devDependencies": {
"@testing-library/react-hooks": "^8.0.1",
"@types/bun": "1.3.11",
"@types/node": "25.5.0",
"@types/react": "19.2.14",
"react-test-renderer": "^19.2.6",
"tsx": "^4.21.0",
"typescript": "5.9.3"
},
Expand Down
1 change: 1 addition & 0 deletions scripts/externals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const SDK_EXTERNALS: string[] = [...COMMON_EXTERNALS, ...SDK_ONLY_EXTERNA
// Packages intentionally bundled (not external, not flagged by validation)
// These are small utilities that are fine to inline into the output bundle.
export const INTENTIONALLY_BUNDLED: string[] = [
// Test utilities (bundled, not external)
// Anthropic provider variants (bundled, not the main SDK)
'@anthropic-ai/bedrock-sdk',
'@anthropic-ai/foundry-sdk',
Expand Down
116 changes: 116 additions & 0 deletions src/ink/hooks/use-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'bun:test'

// Hoist the mock so it's available before module load
const mockSetRawMode = vi.fn()
const mockEventEmitter = { on: vi.fn(), removeListener: vi.fn(), emit: vi.fn() }

vi.mock('./use-stdin.js', () => ({
default: () => ({
setRawMode: mockSetRawMode,
internal_exitOnCtrlC: false,
internal_eventEmitter: mockEventEmitter,
}),
}))

// Dynamic import after mock is registered
const { renderHook, act } = await import('@testing-library/react-hooks')
const useInput = (await import('./use-input.js')).default

describe('useInput — raw mode lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSetRawMode.mockClear()
})

afterEach(() => {
vi.useRealTimers()
})

it('enables raw mode on mount when isActive is true (default)', () => {
const handler = vi.fn()
renderHook(() => useInput(handler))

expect(mockSetRawMode).toHaveBeenCalledWith(true)
})

it('does NOT enable raw mode when isActive is false', () => {
const handler = vi.fn()
renderHook(() => useInput(handler, { isActive: false }))

expect(mockSetRawMode).not.toHaveBeenCalled()
})

it('defers setRawMode(false) on unmount — fires after one tick', () => {
const handler = vi.fn()
const { unmount } = renderHook(() => useInput(handler))

unmount()

// Immediately after unmount, setRawMode(false) has NOT yet fired
expect(mockSetRawMode).toHaveBeenCalledTimes(1) // only the initial true
expect(mockSetRawMode).toHaveBeenCalledWith(true)

// Advance timers — the deferred reset fires
vi.advanceTimersByTime(1)

expect(mockSetRawMode).toHaveBeenCalledTimes(2)
expect(mockSetRawMode).toHaveBeenLastCalledWith(false)
})

it('cancels deferred reset on isActive false→true transition (MCP re-render churn)', () => {
const handler = vi.fn()
const { rerender, unmount } = renderHook(
({ isActive }: { isActive: boolean }) => useInput(handler, { isActive }),
{ initialProps: { isActive: true } },
)

expect(mockSetRawMode).toHaveBeenCalledTimes(1)
expect(mockSetRawMode).toHaveBeenCalledWith(true)

// Toggle to inactive — effect cleanup schedules deferred reset
rerender({ isActive: false })

// Cleanup ran: timer scheduled but hasn't fired yet
expect(mockSetRawMode).toHaveBeenCalledTimes(1)

// Toggle back to active before timer fires — setup cancels pending reset
rerender({ isActive: true })

// Setup cleared the pending timer but did NOT call setRawMode(true)
// because the counter was never decremented by the deferred reset.
expect(mockSetRawMode).toHaveBeenCalledTimes(1)
expect(mockSetRawMode).toHaveBeenLastCalledWith(true)

// Advance timers — the cancelled reset should NOT fire
vi.advanceTimersByTime(100)

// Still only 1 call (no setRawMode(false), no redundant setRawMode(true))
expect(mockSetRawMode).toHaveBeenCalledTimes(1)

// Clean up: final unmount fires the deferred reset
unmount()
vi.advanceTimersByTime(1)

expect(mockSetRawMode).toHaveBeenCalledTimes(2)
expect(mockSetRawMode).toHaveBeenLastCalledWith(false)
})

it('handles isActive true → false transition: disables raw mode', () => {
const handler = vi.fn()
const { rerender } = renderHook(
({ isActive }) => useInput(handler, { isActive }),
{ initialProps: { isActive: true } },
)

expect(mockSetRawMode).toHaveBeenCalledWith(true)

// Transition to inactive
rerender({ isActive: false })

// The effect cleanup from the previous isActive=true run
// schedules a deferred reset
vi.advanceTimersByTime(1)

expect(mockSetRawMode).toHaveBeenLastCalledWith(false)
})
})
33 changes: 30 additions & 3 deletions src/ink/hooks/use-input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect } from 'react'
import { useLayoutEffect, useRef } from 'react'
import { useEventCallback } from 'usehooks-ts'
import type { InputEvent, Key } from '../events/input-event.js'
import useStdin from './use-stdin.js'
Expand Down Expand Up @@ -42,6 +42,10 @@ type Options = {
const useInput = (inputHandler: Handler, options: Options = {}) => {
const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()

// Timer handle for the deferred raw-mode reset. Persists across renders
// so the setup phase of a remount can cancel a pending reset from cleanup.
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
// during React's commit phase, before render() returns. With useEffect, raw
// mode setup is deferred to the next event loop tick via React's scheduler,
Expand All @@ -52,10 +56,32 @@ const useInput = (inputHandler: Handler, options: Options = {}) => {
return
}

setRawMode(true)
// If a prior cleanup scheduled a deferred reset (MCP re-render churn),
// cancel it and skip setRawMode(true). The counter was never decremented
// — the reset was deferred via setTimeout and aborted before it fired —
// so calling setRawMode(true) again would over-increment the counter
// and leak raw mode on final unmount.
if (resetTimerRef.current !== null) {
clearTimeout(resetTimerRef.current)
resetTimerRef.current = null
} else {
setRawMode(true)
}

return () => {
setRawMode(false)
// Defer the raw-mode reset by one macrotask instead of calling it
// synchronously. During MCP async re-render churn the component
// unmounts and remounts within a single React commit — the remount's
// setup clears this timer before it fires, so raw mode is never
// actually disabled and the stdin listener stays registered.
//
// For a genuine unmount (navigation, isActive→false, process exit)
// no remount cancels the timer, so it fires on the next tick and
// properly restores cooked mode.
resetTimerRef.current = setTimeout(() => {
setRawMode(false)
resetTimerRef.current = null
}, 0)
}
}, [options.isActive, setRawMode])

Expand Down Expand Up @@ -100,3 +126,4 @@ const useInput = (inputHandler: Handler, options: Options = {}) => {
}

export default useInput

1 change: 1 addition & 0 deletions src/services/compact/cachedMCConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getCachedMCConfig } from './cachedMicrocompact.js'
Empty file.