Skip to content

Commit 3ab1396

Browse files
kbwoclaude
andauthored
fix: guard SelectInput callbacks against undefined and replace unsafe as-casts (#303)
* fix: guard Menu/Dashboard SelectInput callbacks against undefined ink-select-input can invoke onSelect/onHighlight with undefined when the items list is empty (e.g. immediately after returning from a session), causing a crash on item.value access. Replace the item as MenuItem cast — which hid the undefined at compile time — with an items.find() lookup. Both callbacks now return early when the raw argument is falsy or when no matching item is found, making the undefined case and type safety explicit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace unsafe as-casts with type-safe alternatives Audit all as-casts in src/ and replace each with a safe alternative: - ConfigureCommand: narrow handler signatures from {value: string} to {value: StateDetectionStrategy}; cast drops away - ConfigureMerge: introduce MenuItemValue union type; TypeScript narrows to EditField after 'separator'/'back' guards - useGitStatus: remove redundant failure.value as GitError — Cause.failureOption already returns Option<GitError> - autoApprovalVerifier: error as Error → instanceof guard with fallback - projectManager (×3): as NodeJS.ErrnoException/Error → typeof object + 'code' in error narrowing (handles both real Errors and plain objects) - sessionManager (×2): (error as Error)?.message → instanceof guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5d879da commit 3ab1396

9 files changed

Lines changed: 65 additions & 43 deletions

File tree

bun.lock

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/ConfigureCommand.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,15 @@ const ConfigureCommand: React.FC<ConfigureCommandProps> = ({onComplete}) => {
284284
}
285285
};
286286

287-
const handleStrategySelect = (item: {label: string; value: string}) => {
287+
const handleStrategySelect = (item: {
288+
label: string;
289+
value: StateDetectionStrategy;
290+
}) => {
288291
const preset = presets.find(p => p.id === selectedPresetId);
289292
if (!preset) return;
290293

291294
const updatedPreset = {...preset};
292-
updatedPreset.detectionStrategy = item.value as StateDetectionStrategy;
295+
updatedPreset.detectionStrategy = item.value;
293296

294297
const updatedPresets = presets.map(p =>
295298
p.id === preset.id ? updatedPreset : p,
@@ -300,8 +303,11 @@ const ConfigureCommand: React.FC<ConfigureCommandProps> = ({onComplete}) => {
300303
setIsSelectingStrategy(false);
301304
};
302305

303-
const handleAddStrategySelect = (item: {label: string; value: string}) => {
304-
const strategy = item.value as StateDetectionStrategy;
306+
const handleAddStrategySelect = (item: {
307+
label: string;
308+
value: StateDetectionStrategy;
309+
}) => {
310+
const strategy = item.value;
305311
const defaultCommand = DEFAULT_COMMANDS[strategy];
306312

307313
setNewPreset({

src/components/ConfigureMerge.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface ConfigureMergeProps {
1111
}
1212

1313
type EditField = 'mergeArgs' | 'rebaseArgs';
14+
type MenuItemValue = EditField | 'separator' | 'back';
1415

1516
const DEFAULT_MERGE_ARGS = ['--no-ff'];
1617
const DEFAULT_REBASE_ARGS: string[] = [];
@@ -33,7 +34,7 @@ const ConfigureMerge: React.FC<ConfigureMergeProps> = ({onComplete}) => {
3334
const formatArgs = (args: string[]) =>
3435
args.length > 0 ? args.join(' ') : '(none)';
3536

36-
const menuItems = [
37+
const menuItems: Array<{label: string; value: MenuItemValue}> = [
3738
{
3839
label: `Merge Arguments: ${formatArgs(getMergeArgs())}`,
3940
value: 'mergeArgs',
@@ -46,16 +47,15 @@ const ConfigureMerge: React.FC<ConfigureMergeProps> = ({onComplete}) => {
4647
{label: '<- Back', value: 'back'},
4748
];
4849

49-
const handleSelect = (item: {label: string; value: string}) => {
50+
const handleSelect = (item: {label: string; value: MenuItemValue}) => {
5051
if (item.value === 'separator') return;
5152
if (item.value === 'back') {
5253
onComplete();
5354
return;
5455
}
5556

56-
const field = item.value as EditField;
57-
setEditField(field);
58-
switch (field) {
57+
setEditField(item.value);
58+
switch (item.value) {
5959
case 'mergeArgs':
6060
setInputValue(getMergeArgs().join(' '));
6161
break;

src/components/Dashboard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,11 @@ const Dashboard: React.FC<DashboardProps> = ({
654654
>
655655
<SelectInput
656656
items={items}
657-
onSelect={item => handleSelect(item as DashboardItem)}
657+
onSelect={raw => {
658+
const item = items.find(i => i.value === raw?.value);
659+
if (!item) return;
660+
handleSelect(item);
661+
}}
658662
isFocused={!displayError}
659663
limit={limit}
660664
initialIndex={selectedIndex}

src/components/Menu.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -580,17 +580,17 @@ const Menu: React.FC<MenuProps> = ({
580580
>
581581
<SelectInput
582582
items={items}
583-
onSelect={item => handleSelect(item as MenuItem)}
584-
onHighlight={item => {
585-
// ink-select-input may call onHighlight with undefined when items are empty
586-
// (e.g., during menu re-mount after returning from a session), so guard it.
587-
if (!item) {
588-
return;
589-
}
590-
const menuItem = item as MenuItem;
591-
if (menuItem.type === 'worktree') {
592-
setHighlightedWorktreePath(menuItem.worktree.path);
593-
setHighlightedSession(menuItem.session);
583+
onSelect={raw => {
584+
const item = items.find(i => i.value === raw?.value);
585+
if (!item) return;
586+
handleSelect(item);
587+
}}
588+
onHighlight={raw => {
589+
const item = items.find(i => i.value === raw?.value);
590+
if (!item) return;
591+
if (item.type === 'worktree') {
592+
setHighlightedWorktreePath(item.worktree.path);
593+
setHighlightedSession(item.session);
594594
}
595595
}}
596596
isFocused={!error}

src/hooks/useGitStatus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function handleStatusExit(
128128
} else if (Exit.isFailure(statusExit)) {
129129
const failure = Cause.failureOption(statusExit.cause);
130130
if (Option.isSome(failure)) {
131-
const gitError = failure.value as GitError;
131+
const gitError = failure.value;
132132
update.gitStatus = undefined;
133133
update.gitStatusError = formatGitError(gitError);
134134
hasUpdate = true;

src/services/autoApprovalVerifier.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,8 @@ export class AutoApprovalVerifier {
688688

689689
return JSON.parse(responseText) as AutoApprovalResponse;
690690
},
691-
catch: (error: unknown) => error as Error,
691+
catch: (error: unknown) =>
692+
error instanceof Error ? error : new Error(String(error)),
692693
});
693694

694695
return Effect.catchAll(attemptVerification, (error: Error) => {

src/services/projectManager.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,14 @@ export class ProjectManager implements IProjectManager {
254254
);
255255
} catch (error) {
256256
// Silently skip directories we can't read
257-
if ((error as NodeJS.ErrnoException).code !== 'EACCES') {
257+
if (
258+
!(
259+
typeof error === 'object' &&
260+
error !== null &&
261+
'code' in error &&
262+
error.code === 'EACCES'
263+
)
264+
) {
258265
console.error(`Error scanning directory ${dir}:`, error);
259266
}
260267
}
@@ -351,7 +358,7 @@ export class ProjectManager implements IProjectManager {
351358
return null;
352359
}
353360
} catch (error) {
354-
result.error = `Failed to process: ${(error as Error).message}`;
361+
result.error = `Failed to process: ${error instanceof Error ? error.message : String(error)}`;
355362
}
356363

357364
return result;
@@ -470,11 +477,14 @@ export class ProjectManager implements IProjectManager {
470477
return error;
471478
}
472479

473-
const nodeError = error as NodeJS.ErrnoException;
474-
const cause =
475-
nodeError.code === 'ENOENT'
476-
? `Projects directory does not exist: ${projectsDir}`
477-
: String(error);
480+
const isEnoent =
481+
typeof error === 'object' &&
482+
error !== null &&
483+
'code' in error &&
484+
error.code === 'ENOENT';
485+
const cause = isEnoent
486+
? `Projects directory does not exist: ${projectsDir}`
487+
: String(error);
478488

479489
return new FileSystemError({
480490
operation: 'read',

src/services/sessionManager.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export class SessionManager extends EventEmitter implements ISessionManager {
214214
.catch(async (error: unknown) => {
215215
if (abortController.signal.aborted) {
216216
logger.debug(
217-
`[${session.id}] Auto-approval verification aborted (${(error as Error)?.message ?? 'aborted'})`,
217+
`[${session.id}] Auto-approval verification aborted (${error instanceof Error ? error.message : 'aborted'})`,
218218
);
219219
return;
220220
}
@@ -230,8 +230,9 @@ export class SessionManager extends EventEmitter implements ISessionManager {
230230
await this.updateSessionState(session, 'waiting_input', {
231231
autoApprovalFailed: true,
232232
autoApprovalReason:
233-
(error as Error | undefined)?.message ??
234-
'Auto-approval verification failed',
233+
error instanceof Error
234+
? error.message
235+
: 'Auto-approval verification failed',
235236
});
236237
}
237238
})

0 commit comments

Comments
 (0)