/* @jsxImportSource react */ import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { filterTree, flattenSelection, isAncestorChecked, reducer, visibleNodeIds, type PickerCommand, type PickerState, } from './notion-page-picker-tree.js'; import type { KtxCliIo } from './cli-runtime.js'; const COLOR_THEME = { text: 'white', muted: 'gray', active: 'cyan', warning: 'yellow', } as const; const NO_COLOR_THEME = { text: 'white', muted: 'white', active: 'white', warning: 'white', } as const; type NotionPickerTheme = Record; export interface NotionPickerTuiIo extends KtxCliIo { stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void }; stdout: KtxCliIo['stdout'] & { isTTY?: boolean; columns?: number; rows?: number }; } interface InkKey { leftArrow?: boolean; rightArrow?: boolean; upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean; ctrl?: boolean; backspace?: boolean; delete?: boolean; } export type PickerRenderResult = { kind: 'save'; rootPageIds: string[] } | { kind: 'quit' }; export interface PickerRenderInput { initialState: PickerState; connectionId: string; workspaceLabel: string; cappedAtCount: number | null; currentCrawlMode: 'all_accessible' | 'selected_roots'; } interface NotionPickerAppProps extends PickerRenderInput { terminalRows?: number; terminalWidth?: number; env?: NodeJS.ProcessEnv; onExit(result: PickerRenderResult): void; } export interface NotionPickerInkInstance { rerender(tree: ReactNode): void; unmount(): void; waitUntilExit(): Promise; } export interface NotionPickerInkRenderOptions { stdin?: NotionPickerTuiIo['stdin']; stdout: NotionPickerTuiIo['stdout']; stderr: NotionPickerTuiIo['stderr']; exitOnCtrlC: boolean; patchConsole: boolean; maxFps: number; alternateScreen: boolean; } function resolveTheme(env: NodeJS.ProcessEnv = process.env): NotionPickerTheme { return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME; } export function resolveNotionPickerWidth(columns: number | undefined): number { const resolvedColumns = columns ?? 100; return Math.max(60, Math.min(120, resolvedColumns - 4)); } function staleWarningText(warning: string): string { return warning.includes('stored root_page_ids no longer visible') ? `${warning} - they will be removed if you save` : warning; } function selectedPageCountText(count: number): string { return `${count} selected ${count === 1 ? 'page' : 'pages'}`; } function rowMatchesSearch(state: PickerState, nodeId: string): boolean { const query = state.search.query.trim().toLocaleLowerCase(); if (!query) { return false; } const node = state.byId.get(nodeId); if (!node) { return false; } return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query); } export function sanitizeNotionPickerTuiError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message .replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]') .replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]'); } export function windowOffset(count: number, selected: number, visible: number): number { if (count <= visible) return 0; return Math.max(0, Math.min(count - visible, selected - Math.floor(visible / 2))); } export function windowItems(items: T[], selected: number, visible: number): { items: T[]; offset: number } { const offset = windowOffset(items.length, selected, visible); return { items: items.slice(offset, offset + visible), offset }; } function truncateText(value: string, width: number): string { if (value.length <= width) return value; if (width <= 3) return value.slice(0, width); return `${value.slice(0, width - 3)}...`; } export function notionPickerCommandForInkInput( input: string, key: InkKey, search: PickerState['search'], pendingConfirm: PickerState['pendingConfirm'], ): PickerCommand | null { if (pendingConfirm) { if (input === 'y' || key.return) return 'save-confirm'; if (input === 'n' || key.escape) return 'save-cancel'; if (key.ctrl === true && input === 'c') return 'quit'; return null; } if (search.editing) { if (key.escape) return 'search-cancel'; if (key.return) return 'search-submit'; if (key.backspace || key.delete) return 'search-backspace'; if (key.downArrow) return 'cursor-down'; if (key.upArrow) return 'cursor-up'; if (input.length === 1 && input >= ' ' && input !== '\u007f') return { type: 'search-input', value: input }; return null; } if (key.ctrl === true && input === 'c') return 'quit'; if (key.upArrow) return 'cursor-up'; if (key.downArrow) return 'cursor-down'; if (key.leftArrow) return 'cursor-left'; if (key.rightArrow) return 'cursor-right'; if (key.return) return 'expand'; if (input === ' ') return 'toggle-check'; if (input === '/') return 'search-start'; if (input === 'a') return 'select-all-visible'; if (input === 'n') return 'select-none'; if (input === 's') return 'save-request'; if (input === 'q' || key.escape) return 'quit'; return null; } function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: NotionPickerTheme }): ReactNode { const node = props.state.byId.get(props.nodeId); if (!node) return null; const focused = props.state.cursorId === node.id; const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId); const checked = props.state.checked.has(node.id); const glyph = locked ? '[~]' : checked ? '[×]' : '[ ]'; const children = node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : ''; const prefix = `${focused ? '▸' : ' '} ${glyph} ${' '.repeat(node.depth * 2)}`; const color = focused ? props.theme.active : locked || node.archived ? props.theme.muted : props.theme.text; const title = truncateText(`${node.title}${children}`, Math.max(10, props.width - prefix.length)); const inverse = rowMatchesSearch(props.state, node.id); return ( {prefix} {title} ); } export function NotionPickerApp(props: NotionPickerAppProps): ReactNode { const app = useApp(); const [state, setState] = useState(props.initialState); const stateRef = useRef(state); const theme = useMemo(() => resolveTheme(props.env), [props.env]); const visibleIds = visibleNodeIds(state); const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId)); const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8; const visibleRows = Math.max(5, Math.min(20, (props.terminalRows ?? 24) - reservedRows)); const rows = windowItems(visibleIds, selectedIndex, visibleRows); const hiddenAbove = rows.offset; const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length); const searchMatchCount = filterTree(state).visibleIds.size; const width = resolveNotionPickerWidth(props.terminalWidth); const showSearch = state.search.editing || state.search.query.trim().length > 0; const selectedCount = flattenSelection(state.checked, state.byId).length; stateRef.current = state; useEffect(() => { const hint = state.transientHint; if (!hint) { return; } const clearHint = () => { setState((current) => { const { next } = reducer(current, 'clear-transient-hint'); stateRef.current = next; return next; }); }; const delay = hint.expiresAt - Date.now(); if (delay <= 0) { clearHint(); return; } const timeout = setTimeout(clearHint, delay); return () => clearTimeout(timeout); }, [state.transientHint?.expiresAt]); useInput((input, key) => { const command = notionPickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm); if (!command) { return; } const { next, effect } = reducer(stateRef.current, command); stateRef.current = next; setState(next); if (effect === 'save') { props.onExit({ kind: 'save', rootPageIds: flattenSelection(next.checked, next.byId) }); app.exit(); return; } if (effect === 'quit-without-save') { props.onExit({ kind: 'quit' }); app.exit(); } }); return ( Notion pages visible to integration "{props.workspaceLabel}" {props.cappedAtCount ? {props.cappedAtCount}-page cap reached - some pages not shown : null} {state.preLoadWarnings.map((warning) => ( {staleWarningText(warning)} ))} {showSearch ? ( / {state.search.query} {state.search.editing ? '█' : ''} ({searchMatchCount} matches) ) : null} {hiddenAbove > 0 ? ↑ {hiddenAbove} more : null} {rows.items.map((nodeId) => ( ))} {hiddenBelow > 0 ? ↓ {hiddenBelow} more : null} {state.pendingConfirm === 'mode-switch' ? ( Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to{' '} {selectedPageCountText(selectedCount)}. [y] confirm [esc] back ) : null} {state.transientHint ? {state.transientHint.text} : null} space toggle · enter expand · / search · a all · n none · s save & exit · q quit ); } function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): NotionPickerInkInstance { return renderInkRuntime(tree, { stdin: options.stdin as NodeJS.ReadStream | undefined, stdout: options.stdout as NodeJS.WriteStream, stderr: options.stderr as NodeJS.WriteStream, exitOnCtrlC: options.exitOnCtrlC, patchConsole: options.patchConsole, maxFps: options.maxFps, alternateScreen: options.alternateScreen, }) as NotionPickerInkInstance; } export async function renderNotionPickerTui( input: PickerRenderInput, io: NotionPickerTuiIo, options: { renderInk?: (tree: ReactNode, options: NotionPickerInkRenderOptions) => NotionPickerInkInstance } = {}, ): Promise { let result: PickerRenderResult = { kind: 'quit' }; let instance: NotionPickerInkInstance | null = null; try { instance = (options.renderInk ?? renderInk)( { result = next; instance?.unmount(); }} />, { stdin: io.stdin, stdout: io.stdout, stderr: io.stderr, exitOnCtrlC: false, patchConsole: false, maxFps: 30, alternateScreen: true, }, ); await instance.waitUntilExit(); instance.unmount(); return result; } catch (error) { io.stderr.write( `Notion picker requires a TTY. Use --no-input --notion-root-page-id for scripted mode. ${sanitizeNotionPickerTuiError(error)}\n`, ); return { kind: 'quit' }; } }