feat(cli): redesign Notion page picker UI and add skip-empty flow (#78)

Rework the inline picker to use a cleaner visual style (filled/empty
square glyphs, bordered layout, clack-style header) and streamlined
keybindings (Enter to confirm, Escape to quit, Right Arrow to expand).
Replace the transient "select at least one" hint with a skip-empty
confirmation prompt that exits cleanly via quit-without-save.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-13 17:28:48 -04:00 committed by GitHub
parent 9ecb8cb119
commit f219ba22a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 86 deletions

View file

@ -11,7 +11,6 @@ import {
selectAllVisible, selectAllVisible,
selectNone, selectNone,
toggleChecked, toggleChecked,
TRANSIENT_HINT_DURATION_MS,
visibleNodeIds, visibleNodeIds,
type NotionPickerPageInput, type NotionPickerPageInput,
} from './notion-page-picker-tree.js'; } from './notion-page-picker-tree.js';
@ -223,22 +222,24 @@ describe('bulk actions and reducer effects', () => {
}); });
}); });
it('blocks empty saves, updates search state, and quits without saving', () => { it('prompts skip-empty confirmation on empty save, updates search state, and quits without saving', () => {
const state = buildInitialState({ const state = buildInitialState({
tree: buildPickerTree(pages()), tree: buildPickerTree(pages()),
existingRootPageIds: [], existingRootPageIds: [],
currentCrawlMode: 'selected_roots', currentCrawlMode: 'selected_roots',
}); });
const blockedSave = reducer(state, 'save-request', 9000); const emptySave = reducer(state, 'save-request');
expect(blockedSave).toEqual({ expect(emptySave).toEqual({
next: { next: { ...state, pendingConfirm: 'skip-empty' },
...state, effect: null,
transientHint: { });
text: 'Select at least one page or press q to quit', expect(reducer(emptySave.next, 'save-confirm')).toEqual({
expiresAt: 9000 + TRANSIENT_HINT_DURATION_MS, next: { ...state, pendingConfirm: null },
}, effect: 'quit-without-save',
}, });
expect(reducer(emptySave.next, 'save-cancel')).toEqual({
next: { ...state, pendingConfirm: null },
effect: null, effect: null,
}); });
expect( expect(
@ -262,7 +263,7 @@ describe('bulk actions and reducer effects', () => {
const withHint = { const withHint = {
...state, ...state,
transientHint: { transientHint: {
text: 'Select at least one page or press q to quit', text: 'Select at least one page or press esc to cancel',
expiresAt: 11500, expiresAt: 11500,
}, },
}; };

View file

@ -22,7 +22,7 @@ export interface PickerState {
checked: Set<string>; checked: Set<string>;
cursorId: string; cursorId: string;
search: { editing: boolean; query: string }; search: { editing: boolean; query: string };
pendingConfirm: 'mode-switch' | null; pendingConfirm: 'mode-switch' | 'skip-empty' | null;
preLoadWarnings: string[]; preLoadWarnings: string[];
transientHint: { text: string; expiresAt: number } | null; transientHint: { text: string; expiresAt: number } | null;
currentCrawlMode: 'all_accessible' | 'selected_roots'; currentCrawlMode: 'all_accessible' | 'selected_roots';
@ -61,7 +61,7 @@ interface MutableNode {
childIds: string[]; childIds: string[];
} }
export const TRANSIENT_HINT_DURATION_MS = 2500; const TRANSIENT_HINT_DURATION_MS = 2500;
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true }); const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
@ -444,7 +444,8 @@ export function buildInitialState(args: {
export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } { export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } {
if (state.pendingConfirm) { if (state.pendingConfirm) {
if (cmd === 'save-confirm') { if (cmd === 'save-confirm') {
return { next: cloneState(state, { pendingConfirm: null }), effect: 'save' }; const effect: PickerEffect = state.pendingConfirm === 'skip-empty' ? 'quit-without-save' : 'save';
return { next: cloneState(state, { pendingConfirm: null }), effect };
} }
if (cmd === 'save-cancel') { if (cmd === 'save-cancel') {
return { next: cloneState(state, { pendingConfirm: null }), effect: null }; return { next: cloneState(state, { pendingConfirm: null }), effect: null };
@ -498,19 +499,13 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()
}; };
case 'save-request': case 'save-request':
if (state.checked.size === 0) { if (state.checked.size === 0) {
return { return { next: cloneState(state, { pendingConfirm: 'skip-empty' }), effect: null };
next: cloneState(state, {
transientHint: transientHint('Select at least one page or press q to quit', now),
}),
effect: null,
};
} }
if (state.currentCrawlMode === 'all_accessible') { if (state.currentCrawlMode === 'all_accessible') {
return { next: cloneState(state, { pendingConfirm: 'mode-switch' }), effect: null }; return { next: cloneState(state, { pendingConfirm: 'mode-switch' }), effect: null };
} }
return { next: state, effect: 'save' }; return { next: state, effect: 'save' };
case 'save-confirm': case 'save-confirm':
return { next: state, effect: 'save' };
case 'save-cancel': case 'save-cancel':
return { next: state, effect: null }; return { next: state, effect: null };
case 'quit': case 'quit':

View file

@ -1,7 +1,7 @@
/* @jsxImportSource react */ /* @jsxImportSource react */
import { render as renderInkTest } from 'ink-testing-library'; import { render as renderInkTest } from 'ink-testing-library';
import { act, type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './notion-page-picker-tree.js'; import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './notion-page-picker-tree.js';
import { import {
NotionPickerApp, NotionPickerApp,
@ -70,13 +70,9 @@ function fakeInkInstance(): NotionPickerInkInstance {
} }
function normalizeFrameWrap(frame: string | undefined): string { function normalizeFrameWrap(frame: string | undefined): string {
return frame?.replace(/\n/g, ' ') ?? ''; return frame?.replace(/\n/g, ' ').replace(/│ /g, '').replace(/ +/g, ' ') ?? '';
} }
afterEach(() => {
vi.useRealTimers();
});
describe('notionPickerCommandForInkInput', () => { describe('notionPickerCommandForInkInput', () => {
it('maps browse, search, and confirm input to reducer commands', () => { it('maps browse, search, and confirm input to reducer commands', () => {
expect(notionPickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down'); expect(notionPickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
@ -87,9 +83,11 @@ describe('notionPickerCommandForInkInput', () => {
expect(notionPickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start'); expect(notionPickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
expect(notionPickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible'); expect(notionPickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
expect(notionPickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none'); expect(notionPickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
expect(notionPickerCommandForInkInput('s', {}, state().search, null)).toBe('save-request'); expect(notionPickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request');
expect(notionPickerCommandForInkInput('q', {}, state().search, null)).toBe('quit'); expect(notionPickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit');
expect(notionPickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit'); expect(notionPickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
expect(notionPickerCommandForInkInput('s', {}, state().search, null)).toBeNull();
expect(notionPickerCommandForInkInput('q', {}, state().search, null)).toBeNull();
expect(notionPickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({ expect(notionPickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
type: 'search-input', type: 'search-input',
@ -145,13 +143,16 @@ describe('NotionPickerApp', () => {
); );
const frame = lastFrame() ?? ''; const frame = lastFrame() ?? '';
expect(frame).toContain('Notion pages visible to integration "Design Workspace"'); expect(frame).toContain('Select Notion pages to ingest');
expect(frame).toContain('Workspace: Design Workspace');
expect(frame).toContain('5000-page cap reached - some pages not shown'); expect(frame).toContain('5000-page cap reached - some pages not shown');
expect(frame).toContain('1 stored root_page_ids no longer visible - they will be removed if you save'); expect(frame).toContain('1 stored root_page_ids no longer visible - they will be removed if you save');
expect(frame).toContain('▸ [ ] Engineering Docs ▸ (1)'); expect(frame).toContain(' Engineering Docs ▸ (1)');
expect(frame).toContain(' [ ] Marketing'); expect(frame).toContain(' Marketing');
expect(frame).not.toContain('Search ready: -'); expect(frame).not.toContain('Search ready: -');
expect(frame).toContain('space toggle · enter expand · / search · a all · n none · s save & exit · q quit'); expect(normalizeFrameWrap(frame)).toContain(
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
);
}); });
it('renders partial discovery warnings without stale-root save suffix', () => { it('renders partial discovery warnings without stale-root save suffix', () => {
@ -199,8 +200,8 @@ describe('NotionPickerApp', () => {
); );
const frame = lastFrame() ?? ''; const frame = lastFrame() ?? '';
expect(frame).toContain('▸ [×] Engineering Docs ▾'); expect(frame).toContain(' Engineering Docs ▾');
expect(frame).toContain(' [~] Architecture'); expect(frame).toContain(' Architecture');
}); });
it('supports keyboard selection, all_accessible confirmation, and save callback', async () => { it('supports keyboard selection, all_accessible confirmation, and save callback', async () => {
@ -220,12 +221,12 @@ describe('NotionPickerApp', () => {
stdin.write(' '); stdin.write(' ');
await waitForInkInput(); await waitForInkInput();
expect(lastFrame()).toContain('[×] Engineering Docs'); expect(lastFrame()).toContain(' Engineering Docs');
stdin.write('s'); stdin.write('\r');
await waitForInkInput(); await waitForInkInput();
expect(normalizeFrameWrap(lastFrame())).toContain( expect(normalizeFrameWrap(lastFrame())).toContain(
'Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to 1 selected page. [y] confirm [esc] back', 'Switch crawl_mode from all_accessible to selected_roots? Will limit ingest to 1 selected page. Press Enter to confirm or Escape to go back.',
); );
stdin.write('y'); stdin.write('y');
@ -233,8 +234,7 @@ describe('NotionPickerApp', () => {
expect(onExit).toHaveBeenCalledWith({ kind: 'save', rootPageIds: [IDS.engineering] }); expect(onExit).toHaveBeenCalledWith({ kind: 'save', rootPageIds: [IDS.engineering] });
}); });
it('removes transient hints after their expiry time', async () => { it('prompts skip-empty confirmation on empty submit and dismisses on cancel', async () => {
vi.useFakeTimers();
const onExit = vi.fn(); const onExit = vi.fn();
const { stdin, lastFrame } = renderInkTest( const { stdin, lastFrame } = renderInkTest(
<NotionPickerApp <NotionPickerApp
@ -249,17 +249,25 @@ describe('NotionPickerApp', () => {
/>, />,
); );
await act(async () => { stdin.write('\r');
stdin.write('s'); await waitForInkInput();
await vi.advanceTimersByTimeAsync(10); expect(normalizeFrameWrap(lastFrame())).toContain(
}); 'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.',
expect(lastFrame()).toContain('Select at least one page or press q to quit'); );
await act(async () => {
await vi.advanceTimersByTimeAsync(2500);
});
expect(lastFrame()).not.toContain('Select at least one page or press q to quit');
expect(onExit).not.toHaveBeenCalled(); expect(onExit).not.toHaveBeenCalled();
stdin.write('n');
await waitForInkInput();
expect(lastFrame()).not.toContain('Nothing selected. Skip this step?');
expect(onExit).not.toHaveBeenCalled();
stdin.write('\r');
await waitForInkInput();
expect(lastFrame()).toContain('Nothing selected. Skip this step?');
stdin.write('\r');
await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
}); });
it('renders row-window overflow indicators when the visible list is clipped', async () => { it('renders row-window overflow indicators when the visible list is clipped', async () => {
@ -312,7 +320,7 @@ describe('NotionPickerApp', () => {
/>, />,
); );
stdin.write('q'); stdin.write('\u0003');
await waitForInkInput(); await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' }); expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
}); });

View file

@ -16,6 +16,7 @@ const COLOR_THEME = {
text: 'white', text: 'white',
muted: 'gray', muted: 'gray',
active: 'cyan', active: 'cyan',
selected: 'green',
warning: 'yellow', warning: 'yellow',
} as const; } as const;
@ -23,6 +24,7 @@ const NO_COLOR_THEME = {
text: 'white', text: 'white',
muted: 'white', muted: 'white',
active: 'white', active: 'white',
selected: 'white',
warning: 'white', warning: 'white',
} as const; } as const;
@ -158,13 +160,12 @@ export function notionPickerCommandForInkInput(
if (key.downArrow) return 'cursor-down'; if (key.downArrow) return 'cursor-down';
if (key.leftArrow) return 'cursor-left'; if (key.leftArrow) return 'cursor-left';
if (key.rightArrow) return 'cursor-right'; if (key.rightArrow) return 'cursor-right';
if (key.return) return 'expand'; if (key.return) return 'save-request';
if (input === ' ') return 'toggle-check'; if (input === ' ') return 'toggle-check';
if (input === '/') return 'search-start'; if (input === '/') return 'search-start';
if (input === 'a') return 'select-all-visible'; if (input === 'a') return 'select-all-visible';
if (input === 'n') return 'select-none'; if (input === 'n') return 'select-none';
if (input === 's') return 'save-request'; if (key.escape) return 'quit';
if (input === 'q' || key.escape) return 'quit';
return null; return null;
} }
@ -174,18 +175,27 @@ function PickerRow(props: { state: PickerState; nodeId: string; width: number; t
const focused = props.state.cursorId === node.id; const focused = props.state.cursorId === node.id;
const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId); const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId);
const checked = props.state.checked.has(node.id); const checked = props.state.checked.has(node.id);
const glyph = locked ? '[~]' : checked ? '[×]' : '[ ]'; const isSelected = checked || locked;
const children = const glyph = isSelected ? '◼' : '◻';
const glyphColor = locked ? props.theme.muted : checked ? props.theme.selected : props.theme.muted;
const childAffordance =
node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : ''; node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : '';
const prefix = `${focused ? '▸' : ' '} ${glyph} ${' '.repeat(node.depth * 2)}`; const indent = ' '.repeat(node.depth * 2);
const color = focused ? props.theme.active : locked || node.archived ? props.theme.muted : props.theme.text; const titleColor = focused ? props.theme.text : props.theme.muted;
const title = truncateText(`${node.title}${children}`, Math.max(10, props.width - prefix.length));
const inverse = rowMatchesSearch(props.state, node.id); const inverse = rowMatchesSearch(props.state, node.id);
const prefixWidth = indent.length + 2;
const title = truncateText(`${node.title}${childAffordance}`, Math.max(10, props.width - prefixWidth));
return ( return (
<Text color={color} strikethrough={node.archived}> <Text>
{prefix} <Text color={glyphColor}>
<Text inverse={inverse}>{title}</Text> {indent}
{glyph}
</Text>
<Text color={titleColor} strikethrough={node.archived}>
{' '}
<Text inverse={inverse}>{title}</Text>
</Text>
</Text> </Text>
); );
} }
@ -198,7 +208,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
const visibleIds = visibleNodeIds(state); const visibleIds = visibleNodeIds(state);
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId)); const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8; const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8;
const visibleRows = Math.max(5, Math.min(20, (props.terminalRows ?? 24) - reservedRows)); const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows));
const rows = windowItems(visibleIds, selectedIndex, visibleRows); const rows = windowItems(visibleIds, selectedIndex, visibleRows);
const hiddenAbove = rows.offset; const hiddenAbove = rows.offset;
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length); const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
@ -254,34 +264,60 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={theme.active}>Notion pages visible to integration "{props.workspaceLabel}"</Text> <Text>
{props.cappedAtCount ? <Text color={theme.warning}>{props.cappedAtCount}-page cap reached - some pages not shown</Text> : null} <Text color={theme.active}></Text>
{state.preLoadWarnings.map((warning) => ( <Text bold> Select Notion pages to ingest</Text>
<Text key={warning} color={theme.warning}> </Text>
{staleWarningText(warning)} <Box
</Text> flexDirection="column"
))} borderStyle="single"
{showSearch ? ( borderTop={false}
borderRight={false}
borderBottom={false}
borderColor={theme.active}
paddingLeft={1}
>
<Text color={theme.muted}> <Text color={theme.muted}>
/ {state.search.query} Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape
{state.search.editing ? '█' : ''} ({searchMatchCount} matches) to go back, or Ctrl+C to exit.
</Text> </Text>
) : null} <Text> </Text>
<Box flexDirection="column"> <Text color={theme.muted}>Workspace: {props.workspaceLabel}</Text>
{props.cappedAtCount ? (
<Text color={theme.warning}>{props.cappedAtCount}-page cap reached - some pages not shown</Text>
) : null}
{state.preLoadWarnings.map((warning) => (
<Text key={warning} color={theme.warning}>
{staleWarningText(warning)}
</Text>
))}
{showSearch ? (
<Text>
<Text color={theme.muted}>/ </Text>
<Text>
{state.search.query}
{state.search.editing ? '█' : ''}
</Text>
<Text color={theme.muted}> ({searchMatchCount} matches)</Text>
</Text>
) : null}
{hiddenAbove > 0 ? <Text color={theme.muted}> {hiddenAbove} more</Text> : null} {hiddenAbove > 0 ? <Text color={theme.muted}> {hiddenAbove} more</Text> : null}
{rows.items.map((nodeId) => ( {rows.items.map((nodeId) => (
<PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} /> <PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} />
))} ))}
{hiddenBelow > 0 ? <Text color={theme.muted}> {hiddenBelow} more</Text> : null} {hiddenBelow > 0 ? <Text color={theme.muted}> {hiddenBelow} more</Text> : null}
{state.pendingConfirm === 'mode-switch' ? (
<Text color={theme.warning}>
Switch crawl_mode from all_accessible to selected_roots? Will limit ingest to{' '}
{selectedPageCountText(selectedCount)}. Press Enter to confirm or Escape to go back.
</Text>
) : null}
{state.pendingConfirm === 'skip-empty' ? (
<Text color={theme.warning}>Nothing selected. Skip this step? Press Enter to skip or Escape to go back.</Text>
) : null}
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
</Box> </Box>
{state.pendingConfirm === 'mode-switch' ? ( <Text color={theme.active}></Text>
<Text color={theme.warning}>
Save will switch crawl_mode all_accessible -&gt; selected_roots and limit ingest to{' '}
{selectedPageCountText(selectedCount)}. [y] confirm [esc] back
</Text>
) : null}
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
<Text color={theme.muted}>space toggle · enter expand · / search · a all · n none · s save &amp; exit · q quit</Text>
</Box> </Box>
); );
} }
@ -323,7 +359,7 @@ export async function renderNotionPickerTui(
exitOnCtrlC: false, exitOnCtrlC: false,
patchConsole: false, patchConsole: false,
maxFps: 30, maxFps: 30,
alternateScreen: true, alternateScreen: false,
}, },
); );
await instance.waitUntilExit(); await instance.waitUntilExit();