// PROMPTS - interactive inputs (built on @clack/prompts) import { text, select, confirm, password, isCancel, spinner, autocomplete } from '@clack/prompts'; import readline from 'node:readline'; import fs from 'node:fs'; import path from 'node:path'; import { KobApiClient } from '../core/api.js'; import { getConfig } from '../core/config.js'; import { readEnvFile, writeEnvFile, describeEnvPath } from '../core/env-file.js'; import { formatModel } from '../core/engine.js'; import { C, dim, disableMouse, sanitizeInput, visibleLength, contentWidth } from './theme.js'; import { ensureInteractiveStdin, restoreStdin, snapshotStdin } from './tty.js'; import chalk from 'chalk'; import stringWidth from 'string-width'; export { spinner, isCancel } from '@clack/prompts'; // ── prompt history (persisted to .kob/prompt_history.json) ────────── const HISTORY_LIMIT = 100; const HISTORY_FILE = path.join(process.cwd(), '.kob', 'prompt_history.json'); function loadPromptHistory(): string[] { try { if (!fs.existsSync(HISTORY_FILE)) return []; const raw = fs.readFileSync(HISTORY_FILE, 'utf-8'); const data = JSON.parse(raw); if (Array.isArray(data?.entries)) return data.entries.filter((s: unknown) => typeof s === 'string'); } catch { /* ignore */ } return []; } function savePromptHistory(entries: string[]): void { try { fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true }); fs.writeFileSync(HISTORY_FILE, JSON.stringify({ entries }, null, 2), 'utf-8'); } catch { /* ignore */ } } function recordPromptEntry(value: string): void { const trimmed = value.trim(); if (!trimmed) return; const list = loadPromptHistory(); if (list.length && list[list.length - 1] === trimmed) return; // no dupes list.push(trimmed); while (list.length > HISTORY_LIMIT) list.shift(); savePromptHistory(list); } type SelectOption = { value: string; label: string; hint?: string; }; const INPUT_CURSOR = chalk.hex(C.cyan).bold('_'); const SLASH_OPTIONS = [ { value: '/chat', label: '/chat Open conversation' }, { value: '/ask', label: '/ask Q&A about your code' }, { value: '/plan', label: '/plan Architect a solution' }, { value: '/code', label: '/code Build & edit autonomously' }, { value: '/models', label: '/models Switch AI model' }, { value: '/config', label: '/config Edit global KOB settings' }, { value: '/config-info', label: '/config-info Show current configuration' }, { value: '/config:provider', label: '/config:provider Change API provider URL' }, { value: '/config:key', label: '/config:key Change API key' }, { value: '/config:model', label: '/config:model Change default model' }, { value: '/diff', label: '/diff Show last changes' }, { value: '/undo', label: '/undo Revert last changes' }, { value: '/git', label: '/git Repo status' }, { value: '/find', label: '/find Find text in files' }, { value: '/replace', label: '/replace Replace text locally' }, { value: '/open', label: '/open Open and read a file' }, { value: '/init', label: '/init Scaffold AGENTS.md' }, { value: '/tokens', label: '/tokens Usage this session' }, { value: '/project:list', label: '/project:list List and switch projects' }, { value: '/project:create', label: '/project:create Create a new project' }, { value: '/project:delete', label: '/project:delete Remove a project' }, { value: '/clear', label: '/clear Clear session' }, { value: '/help', label: '/help Show all commands' }, { value: '/upgrade', label: '/upgrade Update KOB CLI to latest' }, { value: '/exit', label: '/exit Quit' }, ]; export async function promptInput(message: string): Promise { disableMouse(); if (!process.stdin.isTTY || !process.stdout.isTTY) { const result = await text({ message }); if (isCancel(result)) return null; return sanitizeInput((result as string) ?? ''); } return await new Promise((resolve) => { const stdin = process.stdin; const stdout = process.stdout; const stdinSnapshot = snapshotStdin(stdin); let value = ''; let cursor = 0; let cursorVisible = true; let settled = false; let blinkTimer: NodeJS.Timeout | undefined; let rawGuardTimer: NodeJS.Timeout | undefined; let renderedLines = 1; const promptHistory = loadPromptHistory(); let historyIndex = -1; let tempValue = ''; // ── slash-command suggestions ──────────────────────── let showSuggestions = false; let suggestedCommands: typeof SLASH_OPTIONS = []; let selectedSuggestion = 0; let suggestionScrollOffset = 0; // index of first visible item /** How many suggestion rows fit below the prompt line. */ const suggestionRows = () => { const termRows = process.stdout.rows || 24; // 1 row for the prompt itself, 1 margin return Math.max(3, termRows - 8); }; const updateSuggestions = () => { if (!value.startsWith('/') || value === '/') { // Show all commands when only "/" is typed suggestedCommands = value === '/' ? SLASH_OPTIONS : []; showSuggestions = value === '/'; selectedSuggestion = 0; suggestionScrollOffset = 0; return; } const partial = value.slice(1).toLowerCase(); // Only show suggestions for partial command names (no arguments yet) if (partial.includes(' ')) { showSuggestions = false; return; } const filtered = SLASH_OPTIONS.filter((opt) => { const cmd = opt.value.slice(1).toLowerCase(); return cmd.startsWith(partial); }); suggestedCommands = filtered; showSuggestions = filtered.length > 0; selectedSuggestion = 0; suggestionScrollOffset = 0; }; /** Ensure selectedSuggestion is within the visible scroll window. */ const clampScroll = () => { const maxVisible = suggestionRows(); if (selectedSuggestion < suggestionScrollOffset) { suggestionScrollOffset = selectedSuggestion; } else if (selectedSuggestion >= suggestionScrollOffset + maxVisible) { suggestionScrollOffset = selectedSuggestion - maxVisible + 1; } }; /** * Split a (possibly ANSI-decorated) string into display lines of at most * `maxWidth` visual columns. ANSI escape sequences are kept as atomic units; * CJK / emoji double-width characters are measured via string-width. */ function splitAnsiChars(text: string, maxWidth: number): string[] { const lines: string[] = []; const rawLines = text.split('\n'); for (const rawLine of rawLines) { const chars = [...rawLine]; let i = 0; while (i < chars.length) { let seg = ''; let segWidth = 0; while (i < chars.length && segWidth < maxWidth) { // ANSI escape sequence → keep whole if (chars[i] === '\x1b') { let ansi = '\x1b'; i++; while (i < chars.length) { ansi += chars[i]!; const c = chars[i]!; i++; if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) break; } seg += ansi; continue; } const ch = chars[i]!; const w = stringWidth(ch) || 1; if (segWidth + w > maxWidth) break; seg += ch; segWidth += w; i++; } lines.push(seg); } } return lines.length > 0 ? lines : ['']; } readline.emitKeypressEvents(stdin); ensureInteractiveStdin(stdin); // On Windows terminals, switching focus away and clicking back can // silently drop raw mode. Reassert it while the prompt is active so // typing resumes immediately after focus returns. rawGuardTimer = setInterval(() => ensureInteractiveStdin(stdin), 250); // Rely on the OS terminal cursor for the blinking caret. Hiding it // with `\x1b[?25l` is silently ignored on legacy Windows PowerShell // (no VT processing), which left the OS cursor blinking on top of our // drawn `_` and produced a visible flicker. We don't draw a custom // underscore anymore — the terminal cursor is enough. const renderValue = () => { const before = value.slice(0, cursor); const current = value[cursor]; const after = value.slice(cursor + 1); if (cursor >= value.length) return before; return `${before}${chalk.hex(C.cyan).underline(current)}${after}`; }; const render = () => { // Move up to the first line of the previous render. // Use direct ANSI sequences — readline.moveCursor with negative dy is // unreliable on Windows terminals when the prompt has wrapped lines. if (renderedLines > 1) { stdout.write(`\x1b[${renderedLines - 1}A`); } stdout.write('\r\x1b[0J'); // carriage-return + clear-to-end-of-screen const content = renderValue(); const fullText = `${message} ${content}`; // Write line-by-line with explicit newlines so we control wrapping, // avoiding terminal auto-wrap that can cause ghost/duplicate text. const w = contentWidth(); const wrapLines = splitAnsiChars(fullText, w); for (let i = 0; i < wrapLines.length; i++) { if (i > 0) stdout.write('\n'); stdout.write(wrapLines[i]!); } renderedLines = wrapLines.length; // ── slash-command suggestions ──────────────────────── if (showSuggestions && suggestedCommands.length > 0) { const maxVisible = suggestionRows(); const visible = suggestedCommands.slice(suggestionScrollOffset, suggestionScrollOffset + maxVisible); for (let i = 0; i < visible.length; i++) { stdout.write('\n'); const opt = visible[i]!; const isSelected = (suggestionScrollOffset + i) === selectedSuggestion; const prefix = isSelected ? chalk.hex(C.cyan)('▸ ') : ' '; const line = isSelected ? chalk.bgHex('#1a3a4a').white(opt.label) : chalk.hex(C.slate)(opt.label); stdout.write(prefix + line); } // Show scroll indicator if there are more items const total = suggestedCommands.length; if (total > suggestionScrollOffset + visible.length) { stdout.write('\n ' + chalk.hex(C.slate)('↓ ' + (total - suggestionScrollOffset - visible.length) + ' more')); } renderedLines += visible.length; if (total > suggestionScrollOffset + visible.length) renderedLines += 1; } }; const refreshCursor = () => { cursorVisible = true; render(); }; const cleanup = (newline = true) => { if (settled) return; settled = true; stdin.off('keypress', onKeypress); if (blinkTimer) clearInterval(blinkTimer); if (rawGuardTimer) clearInterval(rawGuardTimer); restoreStdin(stdin, stdinSnapshot); if (newline) { // Move cursor below any wrapped prompt rows before the next // thing (banner / exchange / etc.) is written. for (let i = 0; i < renderedLines; i++) stdout.write('\n'); } }; const finish = (result: string | null, newline = true) => { cleanup(newline); resolve(result); }; const onKeypress = async (sequence: string, key: { name?: string; ctrl?: boolean }) => { if (key.ctrl && key.name === 'c') { finish(null); return; } if (key.ctrl && key.name === 'a') { cursor = 0; refreshCursor(); return; } if (key.ctrl && key.name === 'e') { cursor = value.length; refreshCursor(); return; } if (key.name === 'escape') { if (showSuggestions) { showSuggestions = false; refreshCursor(); return; } // Escape without suggestions → let it fall through to the // sanity guard below, preserving existing behaviour. } if (key.name === 'return' || key.name === 'enter') { if (showSuggestions && suggestedCommands.length > 0) { const chosen = suggestedCommands[selectedSuggestion]; if (chosen) { const out = sanitizeInput(chosen.value); recordPromptEntry(out); finish(out); return; } } const out = sanitizeInput(value); recordPromptEntry(out); finish(out); return; } if (key.name === 'left') { cursor = Math.max(0, cursor - 1); refreshCursor(); return; } if (key.name === 'right') { cursor = Math.min(value.length, cursor + 1); refreshCursor(); return; } if (key.name === 'home') { cursor = 0; refreshCursor(); return; } if (key.name === 'end') { cursor = value.length; refreshCursor(); return; } if (key.name === 'backspace') { if (cursor > 0) { value = value.slice(0, cursor - 1) + value.slice(cursor); cursor -= 1; } updateSuggestions(); refreshCursor(); return; } if (key.name === 'delete') { if (cursor < value.length) { value = value.slice(0, cursor) + value.slice(cursor + 1); } updateSuggestions(); refreshCursor(); return; } if (key.name === 'tab') { if (showSuggestions && suggestedCommands.length > 0) { const chosen = suggestedCommands[selectedSuggestion] ?? suggestedCommands[0]; if (chosen) { value = chosen.value; cursor = value.length; showSuggestions = false; refreshCursor(); return; } } return; } if (key.ctrl && key.name === 'u') { value = ''; cursor = 0; updateSuggestions(); refreshCursor(); return; } // ── suggestion navigation (overrides history when suggestions visible) ─ if (key.name === 'up') { if (showSuggestions && suggestedCommands.length > 0) { selectedSuggestion = Math.max(0, selectedSuggestion - 1); clampScroll(); refreshCursor(); return; } if (promptHistory.length === 0) return; if (historyIndex === -1) tempValue = value; historyIndex = Math.min(promptHistory.length - 1, historyIndex + 1); value = promptHistory[promptHistory.length - 1 - historyIndex] ?? ''; cursor = value.length; refreshCursor(); return; } if (key.name === 'down') { if (showSuggestions && suggestedCommands.length > 0) { selectedSuggestion = Math.min(suggestedCommands.length - 1, selectedSuggestion + 1); clampScroll(); refreshCursor(); return; } if (historyIndex <= -1) return; historyIndex -= 1; value = historyIndex === -1 ? tempValue : (promptHistory[promptHistory.length - 1 - historyIndex] ?? ''); cursor = value.length; refreshCursor(); return; } // Sanity guard: arrow keys / mouse / paste etc. may surface as escape // sequences or undefined on some terminals. Drop them silently. if (sequence === undefined || sequence === null) return; if (sequence.startsWith('\u001b')) return; if (sequence === '' || sequence.length === 0) return; // Typing a new char exits history-walk mode. if (historyIndex !== -1) { historyIndex = -1; tempValue = ''; } value = value.slice(0, cursor) + sequence + value.slice(cursor); cursor += sequence.length; updateSuggestions(); refreshCursor(); }; stdin.on('keypress', onKeypress); // No cursor blink — the terminal's own cursor blink + an underscore // glyph was doubling up and strobing at ~2 Hz. Static cursor is calmer. render(); }); } export async function pickSlashCommand(query?: string): Promise { let queryText = sanitizeInput(query || ''); while (true) { const search = await text({ message: 'Command', placeholder: 'type /config or config', initialValue: queryText, }); if (isCancel(search)) return null; queryText = sanitizeInput((search as string) ?? ''); const raw = queryText.trim(); const space = raw.search(/\s/); const commandToken = (space === -1 ? raw : raw.slice(0, space)).replace(/^\//, ''); const normalized = commandToken.toLowerCase(); const exact = normalized ? SLASH_OPTIONS.find((option) => option.value.slice(1).toLowerCase() === normalized) : undefined; if (exact) { const suffix = space === -1 ? '' : raw.slice(space); return exact.value + suffix; } const filtered = normalized ? SLASH_OPTIONS.filter((option) => { const command = option.value.slice(1).toLowerCase(); const label = option.label.toLowerCase(); return command.startsWith(normalized) || label.includes(normalized); }) : SLASH_OPTIONS; if (filtered.length === 0) { console.log(' ' + chalk.hex(C.amber)('No commands match. Try another search.')); continue; } const result = await select({ message: normalized ? `Commands (${filtered.length} matches)` : 'Commands', options: filtered, maxItems: 10, }); if (isCancel(result)) continue; return result as string; } } export async function promptOpenFile(cwd: string): Promise { const { listProjectFiles } = require('../tools/files.js'); const { relative } = require('node:path'); const files = listProjectFiles(cwd); if (files.length === 0) return null; const options = files.map((abs: string) => { const rel = relative(cwd, abs).replace(/\\/g, '/'); return { value: rel, label: rel }; }); const result = await autocomplete({ message: 'Select file to open / read', options, }); if (isCancel(result)) return null; return result as string; } export async function promptSearchQuery(initialValue = ''): Promise { const result = await text({ message: 'Find text', placeholder: 'ข้อความที่ต้องการหา', initialValue, }); if (isCancel(result)) return null; const query = sanitizeInput((result as string) ?? '').trim(); return query || null; } export async function promptSearchAction( query: string, hasResults: boolean, ): Promise<'replace_all' | 'replace_file' | 'search_again' | 'done' | null> { const options = hasResults ? [ { value: 'replace_all', label: `Replace all "${query}"` }, { value: 'replace_file', label: 'Replace in one file' }, { value: 'search_again', label: 'Search again' }, { value: 'done', label: 'Done' }, ] : [ { value: 'search_again', label: 'Search again' }, { value: 'done', label: 'Done' }, ]; const result = await select({ message: hasResults ? 'What next?' : 'No matches. What next?', options, maxItems: 8, }); if (isCancel(result)) return null; return result as 'replace_all' | 'replace_file' | 'search_again' | 'done'; } export async function promptReplacementText(oldText: string): Promise { const result = await text({ message: `Replace "${oldText}" with`, placeholder: 'ข้อความใหม่', }); if (isCancel(result)) return null; return sanitizeInput((result as string) ?? ''); } export async function pickSearchFile(paths: string[]): Promise { if (paths.length === 0) return null; const options = paths.map((path) => ({ value: path, label: path })); if (options.length > 12) return await pickSearchableOption('Select file to replace', options); const result = await select({ message: 'Select file to replace', options, maxItems: 12, }); if (isCancel(result)) return null; return result as string; } async function pickSearchableOption( message: string, options: SelectOption[], current?: string, ): Promise { ensureInteractiveStdin(process.stdin); const result = await autocomplete({ message, options, initialValue: current, maxItems: 12, }); if (isCancel(result)) return null; return result as string; } function formatPricePer1M(price?: number): string { if (typeof price !== 'number' || Number.isNaN(price)) return 'n/a'; if (price === 0) return '$0'; if (price >= 1) return `$${price.toFixed(2)}`; if (price >= 0.01) return `$${price.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}`; return `$${price.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')}`; } export async function confirmCommand(cmd: string, kind: 'mutating' | 'dangerous'): Promise { const warn = kind === 'dangerous' ? chalk.hex(C.red)('⚠ potentially destructive command') : chalk.hex(C.amber)('run this command?'); const result = await confirm({ message: `${warn}\n ${chalk.dim('$')} ${chalk.bold(cmd)}`, initialValue: kind !== 'dangerous', }); if (isCancel(result)) return false; return result as boolean; } export async function pickModel(current: string): Promise { const cfg = getConfig({ quiet: true }); if (!cfg) return null; const s = spinner(); s.start('Fetching models'); let models: { id: string; display_name?: string; displayName?: string; provider?: string; inputPricePer1M?: number; outputPricePer1M?: number; }[] = []; try { models = await new KobApiClient(cfg).listModels(); s.stop(`Found ${models.length} models`); } catch { s.stop('Could not fetch models'); } if (!models.length) { ensureInteractiveStdin(process.stdin); const manual = await text({ message: 'Enter model id (provider/model)', initialValue: current }); if (isCancel(manual) || !manual) return null; return formatModel(sanitizeInput(manual as string)); } const options = models.map(m => { const displayName = m.display_name || m.displayName || m.id; const priceLabel = `in ${formatPricePer1M(m.inputPricePer1M)}/1M | out ${formatPricePer1M(m.outputPricePer1M)}/1M`; return { value: m.id, label: [ displayName === m.id ? m.id : `${displayName} (${m.id})`, m.provider ? `[${m.provider}]` : '', priceLabel, ].filter(Boolean).join(' '), hint: undefined, }; }); // Ensure stdin is in a clean interactive state before prompting. ensureInteractiveStdin(process.stdin); if (options.length <= 12) { const picked = await select({ message: 'Select AI model', options, initialValue: current, maxItems: 12 }); if (isCancel(picked)) return null; // Model IDs from API are already complete — don't add prefix return picked as string; } // For large model lists, show models with left rail and real-time search. return await pickModelWithSearch(options, current); } async function pickModelWithSearch(options: SelectOption[], current: string): Promise { const stdin = process.stdin; const stdout = process.stdout; const stdinSnapshot = snapshotStdin(stdin); let query = ''; let cursor = 0; let cursorVisible = true; let settled = false; let selectedIndex = 0; let scrollOffset = 0; let renderedLines = 0; let resolvePromise: ((result: string | null) => void) | null = null; const RAIL = chalk.hex(C.slate)('│'); const railLine = (text: string) => ` ${RAIL} ${text}`; const getFiltered = () => { if (!query.trim()) return options; const lower = query.toLowerCase(); return options.filter(o => o.value.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower) ); }; const maxVisible = () => { const termRows = process.stdout.rows || 24; return Math.max(5, termRows - 10); }; const render = () => { if (renderedLines > 1) { readline.moveCursor(stdout, 0, -(renderedLines - 1)); } readline.cursorTo(stdout, 0); readline.clearScreenDown(stdout); const filtered = getFiltered(); const before = query.slice(0, cursor); const after = query.slice(cursor); const cursorChar = cursorVisible ? chalk.hex(C.cyan)('█') : ''; const searchPrompt = `${chalk.hex(C.cyan)('◆')} ${chalk.hex(C.slate)('Search:')} ${chalk.white(before)}${cursorChar}${chalk.white(after)}`; console.log(railLine(searchPrompt)); console.log(railLine(chalk.dim(`${filtered.length} models`))); console.log(railLine(chalk.dim('─'.repeat(40)))); const visible = maxVisible(); const clampedIndex = Math.min(selectedIndex, filtered.length - 1); if (clampedIndex < scrollOffset) { scrollOffset = clampedIndex; } else if (clampedIndex >= scrollOffset + visible) { scrollOffset = clampedIndex - visible + 1; } const toShow = filtered.slice(scrollOffset, scrollOffset + visible); for (let i = 0; i < toShow.length; i++) { const opt = toShow[i]!; const globalIndex = scrollOffset + i; const isCurrent = opt.value === current; const isSelected = globalIndex === clampedIndex; const marker = isSelected ? chalk.hex(C.cyan)('▸') : ' '; const currentMark = isCurrent ? chalk.hex(C.green)('●') : ' '; const line = isSelected ? chalk.bgHex('#1a3a4a').white(opt.label) : chalk.hex(C.slate)(opt.label); console.log(railLine(`${marker} ${currentMark} ${line}`)); } if (filtered.length > visible) { console.log(railLine(chalk.dim(`${scrollOffset + 1}-${scrollOffset + toShow.length} of ${filtered.length}`))); } console.log(railLine(chalk.dim('↑↓ navigate · Enter select · Esc cancel'))); renderedLines = 4 + toShow.length + (filtered.length > visible ? 1 : 0) + 1; }; const cleanup = () => { if (settled) return; settled = true; stdin.off('keypress', onKeypress); restoreStdin(stdin, stdinSnapshot); stdout.write('\x1b[?25h'); }; const finish = (result: string | null) => { cleanup(); readline.cursorTo(stdout, 0); readline.moveCursor(stdout, 0, -renderedLines); readline.clearScreenDown(stdout); if (resolvePromise) { resolvePromise(result); } }; const onKeypress = (sequence: string, key: { name?: string; ctrl?: boolean }) => { if (key.ctrl && key.name === 'c') { finish(null); return; } if (key.name === 'escape') { finish(null); return; } if (key.name === 'return' || key.name === 'enter') { const filtered = getFiltered(); const clampedIndex = Math.min(selectedIndex, filtered.length - 1); const chosen = filtered[clampedIndex]; if (chosen) { // Model IDs from API are already complete — don't add prefix finish(chosen.value); } return; } if (key.name === 'up') { selectedIndex = Math.max(0, selectedIndex - 1); render(); return; } if (key.name === 'down') { const filtered = getFiltered(); selectedIndex = Math.min(filtered.length - 1, selectedIndex + 1); render(); return; } if (key.name === 'backspace') { if (cursor > 0) { query = query.slice(0, cursor - 1) + query.slice(cursor); cursor -= 1; selectedIndex = 0; scrollOffset = 0; } render(); return; } if (key.name === 'delete') { if (cursor < query.length) { query = query.slice(0, cursor) + query.slice(cursor + 1); selectedIndex = 0; scrollOffset = 0; } render(); return; } if (key.name === 'home') { cursor = 0; render(); return; } if (key.name === 'end') { cursor = query.length; render(); return; } if (key.name === 'left') { cursor = Math.max(0, cursor - 1); render(); return; } if (key.name === 'right') { cursor = Math.min(query.length, cursor + 1); render(); return; } if (sequence === undefined || sequence === null) return; if (sequence.startsWith('\u001b')) return; if (sequence === '' || sequence.length === 0) return; query = query.slice(0, cursor) + sequence + query.slice(cursor); cursor += sequence.length; selectedIndex = 0; scrollOffset = 0; render(); }; readline.emitKeypressEvents(stdin); ensureInteractiveStdin(stdin); stdout.write('\x1b[?25l'); stdin.on('keypress', onKeypress); render(); return await new Promise((resolve) => { resolvePromise = resolve; }); } export async function pickProject(projects: { name: string; path: string }[]): Promise { if (projects.length === 0) return null; const options = projects.map(p => ({ value: p.path, label: p.name, hint: p.path, })); const result = options.length > 12 ? await pickSearchableOption('Select project', options) : await select({ message: 'Select project', options, maxItems: 12 }); if (isCancel(result)) return null; return result as string; } export async function promptProjectCreate(): Promise<{ name: string; path: string } | null> { const nameResult = await text({ message: 'Project name', placeholder: 'my-awesome-project' }); if (isCancel(nameResult) || !nameResult) return null; const name = sanitizeInput(nameResult as string).trim(); const pathResult = await text({ message: 'Project absolute path', placeholder: process.cwd(), initialValue: process.cwd() }); if (isCancel(pathResult) || !pathResult) return null; const projectPath = sanitizeInput(pathResult as string).trim(); return { name, path: projectPath }; } export async function pickProjectToDelete(projects: { name: string; path: string }[]): Promise { if (projects.length === 0) return null; const options = projects.map(p => ({ value: p.name, label: p.name, hint: p.path, })); const result = options.length > 12 ? await pickSearchableOption('Select project to delete', options) : await select({ message: 'Select project to delete', options, maxItems: 12 }); if (isCancel(result)) return null; return result as string; } export async function promptConfigText( message: string, envKey: string, placeholder: string, ): Promise { const env = readEnvFile(); const currentValue = env[envKey] || ''; const result = await text({ message, placeholder, initialValue: currentValue, }); if (isCancel(result)) return null; const val = sanitizeInput(result as string); if (val) { writeEnvFile({ [envKey]: val }); process.env[envKey] = val; } return val; } export async function promptConfigPassword(message: string): Promise { ensureInteractiveStdin(process.stdin); const result = await password({ message }); if (isCancel(result)) return null; const val = result as string; if (val) { writeEnvFile({ KOB_API_KEY: val }); process.env.KOB_API_KEY = val; } return val; } export async function editConfig(): Promise { const env = readEnvFile(); console.log(' ' + chalk.dim('editing ' + describeEnvPath())); ensureInteractiveStdin(process.stdin); const key = await password({ message: 'API key (kob_xxx:token) — leave blank to keep current' }); if (isCancel(key)) return false; const model = await text({ message: 'Default model id', placeholder: env.KOB_MODEL_ID || 'deepseek/deepseek-v4-flash', initialValue: env.KOB_MODEL_ID || '', }); if (isCancel(model)) return false; const url = await text({ message: 'API base URL', placeholder: 'https://www.kob-ai.dev', initialValue: env.KOB_API_BASE_URL || 'https://www.kob-ai.dev', }); if (isCancel(url)) return false; const updates: Record = {}; if (key) { updates.KOB_API_KEY = key as string; process.env.KOB_API_KEY = key as string; } if (model) { updates.KOB_MODEL_ID = sanitizeInput(model as string); process.env.KOB_MODEL_ID = updates.KOB_MODEL_ID; } if (url) { updates.KOB_API_BASE_URL = sanitizeInput(url as string); process.env.KOB_API_BASE_URL = updates.KOB_API_BASE_URL; } if (Object.keys(updates).length > 0) writeEnvFile(updates); return true; }