From d8a82c31e27d5e213109f4db58303a761d7f746e Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 19 Mar 2026 22:59:50 +0100 Subject: [PATCH] feat: add startup API key modal (#168) * feat: add startup API key modal Allow the interactive app to boot without a preconfigured API key and guide users into adding one in-app instead of exiting early. Persist explicit auth and model preferences so first-run setup and subsequent launches feel consistent. * bump version * format * fix lint issues --- package.json | 2 +- src/agent/agent.ts | 52 +++- src/index.ts | 16 +- src/ui/app.tsx | 646 +++++++++++++++++++++++++++--------------- src/utils/settings.ts | 23 +- 5 files changed, 491 insertions(+), 248 deletions(-) diff --git a/package.json b/package.json index 63c5641..ec867d8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@vibe-kit/grok-cli", + "name": "grok-dev", "version": "1.0.0", "description": "An open-source AI coding agent powered by Grok, built with Bun and OpenTUI.", "type": "module", diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 5bb2d19..f993c20 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -182,7 +182,9 @@ function buildSubagentPrompt(request: TaskRequest, cwd: string): string { } export class Agent { - private provider: XaiProvider; + private provider: XaiProvider | null = null; + private apiKey: string | null = null; + private baseURL: string | null = null; private bash: BashTool; private delegations: DelegationManager; private sessionStore: SessionStore | null = null; @@ -198,8 +200,17 @@ export class Agent { private planContext: string | null = null; private subagentStatusListeners = new Set<(status: SubagentStatus | null) => void>(); - constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number, options: AgentOptions = {}) { - this.provider = createProvider(apiKey, baseURL); + constructor( + apiKey: string | undefined, + baseURL?: string, + model?: string, + maxToolRounds?: number, + options: AgentOptions = {}, + ) { + this.baseURL = baseURL || null; + if (apiKey) { + this.setApiKey(apiKey, baseURL); + } this.bash = new BashTool(); this.delegations = new DelegationManager(() => this.bash.getCwd()); this.modelId = model || "grok-4-1-fast"; @@ -248,6 +259,16 @@ export class Agent { this.planContext = ctx; } + hasApiKey(): boolean { + return !!this.apiKey; + } + + setApiKey(apiKey: string, baseURL = this.baseURL ?? undefined): void { + this.apiKey = apiKey; + this.baseURL = baseURL || null; + this.provider = createProvider(apiKey, baseURL); + } + getCwd(): string { return this.bash.getCwd(); } @@ -277,7 +298,12 @@ export class Agent { } async generateTitle(userMessage: string): Promise { - const generated = await genTitle(this.provider, userMessage); + const provider = this.provider; + if (!provider) { + return "New session"; + } + + const generated = await genTitle(provider, userMessage); this.recordUsage(generated.usage, "title", "grok-3-mini-fast"); if (this.sessionStore && this.session && !this.session.title && generated.title) { this.sessionStore.setTitle(this.session.id, generated.title); @@ -393,10 +419,11 @@ export class Agent { onActivity?: (detail: string) => void, abortSignal?: AbortSignal, ): Promise { + const provider = this.requireProvider(); const signal = abortSignal; const childMode: AgentMode = request.agent === "explore" ? "ask" : "agent"; const childBash = new BashTool(this.bash.getCwd()); - const childTools = createTools(childBash, this.provider, childMode); + const childTools = createTools(childBash, provider, childMode); const initialDetail = request.agent === "explore" ? "Scanning the codebase" : "Planning delegated work"; let assistantText = ""; let lastActivity = initialDetail; @@ -405,7 +432,7 @@ export class Agent { try { const result = streamText({ - model: this.provider(request.agent === "explore" ? "grok-4-1-fast" : this.modelId), + model: provider(request.agent === "explore" ? "grok-4-1-fast" : this.modelId), system: buildSubagentPrompt(request, childBash.getCwd()), messages: [{ role: "user", content: request.prompt }], tools: childTools, @@ -565,7 +592,8 @@ export class Agent { let streamOk = false; try { - const tools = createTools(this.bash, this.provider, this.mode, { + const provider = this.requireProvider(); + const tools = createTools(this.bash, provider, this.mode, { runTask: (request, abortSignal) => this.runTask(request, combineAbortSignals(signal, abortSignal)), runDelegation: (request, abortSignal) => this.runDelegation(request, combineAbortSignals(signal, abortSignal)), readDelegation: (id) => this.readDelegation(id), @@ -575,7 +603,7 @@ export class Agent { this.planContext = null; const result = streamText({ - model: this.provider(this.modelId), + model: provider(this.modelId), system, messages: this.messages, tools, @@ -694,6 +722,14 @@ export class Agent { } } } + + private requireProvider(): XaiProvider { + if (!this.provider) { + throw new Error("API key required. Add an API key to continue."); + } + + return this.provider; + } } function toToolCall(part: { toolCallId: string; toolName: string; args?: unknown; input?: unknown }): ToolCall { diff --git a/src/index.ts b/src/index.ts index 5c35159..6bf43fe 100755 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ process.on("unhandledRejection", (reason) => { }); async function startInteractive( - apiKey: string, + apiKey: string | undefined, baseURL: string, model: string, maxToolRounds: number, @@ -138,6 +138,13 @@ function resolveConfig(options: Record) { const model = options.model || getCurrentModel(); const maxToolRounds = parseInt(options.maxToolRounds || "400", 10) || 400; + if (options.apiKey) saveUserSettings({ apiKey: options.apiKey }); + if (options.model) saveUserSettings({ defaultModel: options.model }); + + return { apiKey, baseURL, model, maxToolRounds }; +} + +function requireApiKey(apiKey: string | undefined): string { if (!apiKey) { console.error( "Error: API key required. Set GROK_API_KEY env var, use --api-key, or save to ~/.grok/user-settings.json", @@ -145,10 +152,7 @@ function resolveConfig(options: Record) { process.exit(1); } - if (options.apiKey) saveUserSettings({ apiKey: options.apiKey }); - if (options.baseUrl) saveUserSettings({ baseURL: options.baseUrl }); - - return { apiKey, baseURL, model, maxToolRounds }; + return apiKey; } program @@ -185,7 +189,7 @@ program if (options.prompt) { await runHeadless( options.prompt, - config.apiKey, + requireApiKey(config.apiKey), config.baseURL, config.model, config.maxToolRounds, diff --git a/src/ui/app.tsx b/src/ui/app.tsx index cf6ac8e..38872fa 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -16,7 +16,7 @@ import type { ToolCall, } from "../types/index"; import { MODES } from "../types/index"; -import { saveProjectSettings } from "../utils/settings"; +import { saveProjectSettings, saveUserSettings } from "../utils/settings"; import { Markdown } from "./markdown"; import { formatPlanAnswers, @@ -226,6 +226,8 @@ interface AppProps { export function App({ agent, initialMessage, onExit }: AppProps) { const t = dark; + const initialHasApiKey = agent.hasApiKey(); + const [hasApiKey, setHasApiKey] = useState(initialHasApiKey); const [messages, setMessages] = useState(() => agent.getChatEntries()); const [streamContent, setStreamContent] = useState(""); const [_streamReasoning, setStreamReasoning] = useState(""); @@ -238,6 +240,8 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const [activeToolCalls, setActiveToolCalls] = useState([]); const [sessionTitle, setSessionTitle] = useState(() => agent.getSessionTitle()); const [sessionId, setSessionId] = useState(() => agent.getSessionId()); + const [showApiKeyModal, setShowApiKeyModal] = useState(() => !initialHasApiKey); + const [apiKeyError, setApiKeyError] = useState(null); const [showSlashMenu, setShowSlashMenu] = useState(false); const [slashMenuIndex, setSlashMenuIndex] = useState(0); const [slashSearchQuery, setSlashSearchQuery] = useState(""); @@ -249,6 +253,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const [pqs, setPqs] = useState(initialPlanQuestionsState()); const imageCounterRef = useRef(0); const pasteCounterRef = useRef(0); + const apiKeyInputRef = useRef(null); const inputRef = useRef(null); const scrollRef = useRef(null); const { width, height } = useTerminalDimensions(); @@ -256,6 +261,8 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const contentAccRef = useRef(""); const startTimeRef = useRef(0); const isProcessingRef = useRef(false); + const hasApiKeyRef = useRef(initialHasApiKey); + const showApiKeyModalRef = useRef(!initialHasApiKey); const queuedMessagesRef = useRef([]); const [queuedMessages, setQueuedMessages] = useState([]); const modeInfoRef = useRef<(typeof MODES)[number]>(MODES[0]); @@ -315,6 +322,47 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } }, []); + const openApiKeyModal = useCallback(() => { + showApiKeyModalRef.current = true; + setApiKeyError(null); + setShowApiKeyModal(true); + }, []); + + const closeApiKeyModal = useCallback(() => { + showApiKeyModalRef.current = false; + setApiKeyError(null); + setShowApiKeyModal(false); + }, []); + + const submitApiKey = useCallback(() => { + const apiKey = (apiKeyInputRef.current?.plainText || "").trim(); + if (!apiKey) { + setApiKeyError("Enter an API key to continue."); + return; + } + if (!apiKey.startsWith("xai-")) { + setApiKeyError("API keys should start with xai-."); + return; + } + + saveUserSettings({ apiKey }); + agent.setApiKey(apiKey); + hasApiKeyRef.current = true; + showApiKeyModalRef.current = false; + setHasApiKey(true); + setApiKeyError(null); + setShowApiKeyModal(false); + apiKeyInputRef.current?.clear(); + }, [agent]); + + useEffect(() => { + hasApiKeyRef.current = hasApiKey; + }, [hasApiKey]); + + useEffect(() => { + showApiKeyModalRef.current = showApiKeyModal; + }, [showApiKeyModal]); + const invalidateActiveRun = useCallback(() => { activeRunIdRef.current += 1; setActiveToolCalls([]); @@ -473,11 +521,11 @@ export function App({ agent, initialMessage, onExit }: AppProps) { ); useEffect(() => { - if (initialMessage && !processedInitial.current) { + if (initialMessage && hasApiKey && !processedInitial.current) { processedInitial.current = true; processMessage(initialMessage); } - }, [initialMessage, processMessage]); + }, [hasApiKey, initialMessage, processMessage]); useEffect(() => agent.onSubagentStatus(setActiveSubagent), [agent]); useEffect(() => { let active = true; @@ -636,256 +684,309 @@ export function App({ agent, initialMessage, onExit }: AppProps) { [pqs, isSinglePlan, submitPlanAnswers], ); - useKeyboard((key) => { - if (showPlanPanel) { - const q = planQuestions[pqs.tab]; + const handleKey = useCallback( + (key: { name?: string; sequence?: string; ctrl?: boolean; meta?: boolean; shift?: boolean }) => { + if (showPlanPanel) { + const q = planQuestions[pqs.tab]; - // Escape always dismisses - if (key.name === "escape") { - dismissPlan(); - return; - } + // Escape always dismisses + if (key.name === "escape") { + dismissPlan(); + return; + } - // When editing custom text input - if (pqs.editing && !isPlanConfirmTab) { - if (key.name === "return") { - const qId = q?.id; - if (qId) { - const text = (pqs.customInputs[qId] ?? "").trim(); - if (text) { - if (q.type === "multiselect") { - const existing = (pqs.answers[qId] as string[] | undefined) ?? []; - const next = existing.includes(text) ? existing : [...existing, text]; - setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: next } })); - } else if (q.type === "text") { - setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); - if (isSinglePlan) { - submitPlanAnswers(); - return; + // When editing custom text input + if (pqs.editing && !isPlanConfirmTab) { + if (key.name === "return") { + const qId = q?.id; + if (qId) { + const text = (pqs.customInputs[qId] ?? "").trim(); + if (text) { + if (q.type === "multiselect") { + const existing = (pqs.answers[qId] as string[] | undefined) ?? []; + const next = existing.includes(text) ? existing : [...existing, text]; + setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: next } })); + } else if (q.type === "text") { + setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); + if (isSinglePlan) { + submitPlanAnswers(); + return; + } + setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + } else { + setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); + if (isSinglePlan) { + submitPlanAnswers(); + return; + } + setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); } - setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); } else { - setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); - if (isSinglePlan) { - submitPlanAnswers(); - return; - } - setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + setPqs((s) => ({ ...s, editing: false })); } - } else { - setPqs((s) => ({ ...s, editing: false })); } + return; + } + if (key.name === "backspace") { + const qId = q?.id; + if (qId) + setPqs((s) => ({ + ...s, + customInputs: { ...s.customInputs, [qId]: (s.customInputs[qId] ?? "").slice(0, -1) }, + })); + return; + } + if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { + const qId = q?.id; + if (qId) + setPqs((s) => ({ + ...s, + customInputs: { ...s.customInputs, [qId]: (s.customInputs[qId] ?? "") + key.sequence }, + })); + return; } return; } + + // Tab / left / right — switch between question tabs + if (key.name === "tab") { + const dir = key.shift ? -1 : 1; + setPqs((s) => ({ ...s, tab: (s.tab + dir + planTabCount) % planTabCount, selected: 0 })); + return; + } + if (key.name === "left" || key.name === "h") { + setPqs((s) => ({ ...s, tab: (s.tab - 1 + planTabCount) % planTabCount, selected: 0 })); + return; + } + if (key.name === "right" || key.name === "l") { + setPqs((s) => ({ ...s, tab: (s.tab + 1) % planTabCount, selected: 0 })); + return; + } + + // Confirm tab + if (isPlanConfirmTab) { + if (key.name === "return") { + submitPlanAnswers(); + return; + } + return; + } + + if (!q) return; + + // Text-only question (no options) + if (q.type === "text") { + setPqs((s) => ({ ...s, editing: true })); + return; + } + + // Up/down — navigate options + const options = q.options ?? []; + const showCustom = true; + const totalItems = options.length + 1; + + if (key.name === "up" || key.name === "k") { + setPqs((s) => ({ ...s, selected: (s.selected - 1 + totalItems) % totalItems })); + return; + } + if (key.name === "down" || key.name === "j") { + setPqs((s) => ({ ...s, selected: (s.selected + 1) % totalItems })); + return; + } + + // Number keys 1-9 for quick selection + const digit = Number(key.name); + if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(totalItems, 9)) { + const idx = digit - 1; + setPqs((s) => ({ ...s, selected: idx })); + handlePlanSelect(q, idx, options, showCustom); + return; + } + + // Enter — select current option + if (key.name === "return") { + handlePlanSelect(q, pqs.selected, options, showCustom); + return; + } + + return; + } + if (showApiKeyModalRef.current) { + if (key.name === "escape") { + closeApiKeyModal(); + return; + } + if (key.name === "return") { + submitApiKey(); + } + return; + } + if (showSlashMenu) { + if (key.name === "escape") { + setShowSlashMenu(false); + setSlashSearchQuery(""); + inputRef.current?.clear(); + return; + } + if (key.name === "up") { + setSlashMenuIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === "down") { + setSlashMenuIndex((i) => Math.min(filteredSlashItems.length - 1, i + 1)); + return; + } + if (key.name === "return") { + const item = filteredSlashItems[slashMenuIndex]; + if (item) handleSlashMenuSelect(item); + setSlashSearchQuery(""); + return; + } if (key.name === "backspace") { - const qId = q?.id; - if (qId) - setPqs((s) => ({ - ...s, - customInputs: { ...s.customInputs, [qId]: (s.customInputs[qId] ?? "").slice(0, -1) }, - })); + setSlashSearchQuery((q) => q.slice(0, -1)); + setSlashMenuIndex(0); return; } if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { - const qId = q?.id; - if (qId) - setPqs((s) => ({ - ...s, - customInputs: { ...s.customInputs, [qId]: (s.customInputs[qId] ?? "") + key.sequence }, - })); + setSlashSearchQuery((q) => q + key.sequence); + setSlashMenuIndex(0); return; } return; } - - // Tab / left / right — switch between question tabs - if (key.name === "tab") { - const dir = key.shift ? -1 : 1; - setPqs((s) => ({ ...s, tab: (s.tab + dir + planTabCount) % planTabCount, selected: 0 })); - return; - } - if (key.name === "left" || key.name === "h") { - setPqs((s) => ({ ...s, tab: (s.tab - 1 + planTabCount) % planTabCount, selected: 0 })); - return; - } - if (key.name === "right" || key.name === "l") { - setPqs((s) => ({ ...s, tab: (s.tab + 1) % planTabCount, selected: 0 })); - return; - } - - // Confirm tab - if (isPlanConfirmTab) { + if (showModelPicker) { + if (key.name === "escape") { + setShowModelPicker(false); + setModelSearchQuery(""); + return; + } + if (key.name === "up") { + setModelPickerIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === "down") { + setModelPickerIndex((i) => Math.min(filteredModelIds.length - 1, i + 1)); + return; + } if (key.name === "return") { - submitPlanAnswers(); + const sel = filteredModelIds[modelPickerIndex]; + if (sel) { + agent.setModel(sel); + setModel(sel); + saveProjectSettings({ model: sel }); + saveUserSettings({ defaultModel: sel }); + } + setShowModelPicker(false); + setModelSearchQuery(""); + return; + } + if (key.name === "backspace") { + setModelSearchQuery((q) => q.slice(0, -1)); + setModelPickerIndex(0); + return; + } + if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { + setModelSearchQuery((q) => q + key.sequence); + setModelPickerIndex(0); return; } return; } - - if (!q) return; - - // Text-only question (no options) - if (q.type === "text") { - setPqs((s) => ({ ...s, editing: true })); + if (!hasApiKeyRef.current && shouldOpenApiKeyModalForKey(key)) { + openApiKeyModal(); return; } - - // Up/down — navigate options - const options = q.options ?? []; - const showCustom = true; - const totalItems = options.length + 1; - - if (key.name === "up" || key.name === "k") { - setPqs((s) => ({ ...s, selected: (s.selected - 1 + totalItems) % totalItems })); - return; - } - if (key.name === "down" || key.name === "j") { - setPqs((s) => ({ ...s, selected: (s.selected + 1) % totalItems })); - return; - } - - // Number keys 1-9 for quick selection - const digit = Number(key.name); - if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(totalItems, 9)) { - const idx = digit - 1; - setPqs((s) => ({ ...s, selected: idx })); - handlePlanSelect(q, idx, options, showCustom); - return; - } - - // Enter — select current option - if (key.name === "return") { - handlePlanSelect(q, pqs.selected, options, showCustom); - return; - } - - return; - } - if (showSlashMenu) { - if (key.name === "escape") { - setShowSlashMenu(false); - setSlashSearchQuery(""); - inputRef.current?.clear(); - return; - } - if (key.name === "up") { - setSlashMenuIndex((i) => Math.max(0, i - 1)); - return; - } - if (key.name === "down") { - setSlashMenuIndex((i) => Math.min(filteredSlashItems.length - 1, i + 1)); - return; - } - if (key.name === "return") { - const item = filteredSlashItems[slashMenuIndex]; - if (item) handleSlashMenuSelect(item); - setSlashSearchQuery(""); - return; - } - if (key.name === "backspace") { - setSlashSearchQuery((q) => q.slice(0, -1)); - setSlashMenuIndex(0); - return; - } - if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { - setSlashSearchQuery((q) => q + key.sequence); - setSlashMenuIndex(0); - return; - } - return; - } - if (showModelPicker) { - if (key.name === "escape") { - setShowModelPicker(false); - setModelSearchQuery(""); - return; - } - if (key.name === "up") { - setModelPickerIndex((i) => Math.max(0, i - 1)); - return; - } - if (key.name === "down") { - setModelPickerIndex((i) => Math.min(filteredModelIds.length - 1, i + 1)); - return; - } - if (key.name === "return") { - const sel = filteredModelIds[modelPickerIndex]; - if (sel) { - agent.setModel(sel); - setModel(sel); - saveProjectSettings({ model: sel }); + if (isProcessing && key.name === "escape") { + invalidateActiveRun(); + if (queuedMessagesRef.current.length > 0) { + queuedMessagesRef.current = []; + setQueuedMessages([]); + } else { + agent.abort(); } - setShowModelPicker(false); - setModelSearchQuery(""); return; } - if (key.name === "backspace") { - setModelSearchQuery((q) => q.slice(0, -1)); - setModelPickerIndex(0); + if (key.sequence === "/" && !isProcessing) { + const text = inputRef.current?.plainText || ""; + if (!text.trim()) { + setShowSlashMenu(true); + setSlashMenuIndex(0); + setSlashSearchQuery(""); + return; + } + } + if (key.name === "c" && key.ctrl) { + const text = inputRef.current?.plainText || ""; + if (text.trim()) { + inputRef.current?.clear(); + setPasteBlocks([]); + } else { + onExit ? onExit() : process.exit(0); + } return; } - if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { - setModelSearchQuery((q) => q + key.sequence); - setModelPickerIndex(0); + if (key.name === "tab" && !isProcessing) { + cycleMode(); return; } - return; - } - if (isProcessing && key.name === "escape") { - invalidateActiveRun(); - if (queuedMessagesRef.current.length > 0) { - queuedMessagesRef.current = []; - setQueuedMessages([]); - } else { - agent.abort(); - } - return; - } - if (key.sequence === "/" && !isProcessing) { - const text = inputRef.current?.plainText || ""; - if (!text.trim()) { - setShowSlashMenu(true); - setSlashMenuIndex(0); - setSlashSearchQuery(""); - return; - } - } - if (key.name === "c" && key.ctrl) { - const text = inputRef.current?.plainText || ""; - if (text.trim()) { - inputRef.current?.clear(); - setPasteBlocks([]); - } else { - onExit ? onExit() : process.exit(0); - } - return; - } - if (key.name === "tab" && !isProcessing) { - cycleMode(); - return; - } - }); + }, + [ + agent, + closeApiKeyModal, + cycleMode, + dismissPlan, + filteredModelIds, + filteredSlashItems, + handlePlanSelect, + handleSlashMenuSelect, + invalidateActiveRun, + isPlanConfirmTab, + isProcessing, + isSinglePlan, + modelPickerIndex, + openApiKeyModal, + onExit, + planQuestions, + planTabCount, + pqs, + showModelPicker, + showPlanPanel, + showSlashMenu, + slashMenuIndex, + submitApiKey, + submitPlanAnswers, + ], + ); + useKeyboard(handleKey); - const handlePaste = useCallback((event: PasteEvent) => { - const text = decodePasteBytes(event.bytes); - const trimmed = text.trim(); - const imageExts = /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?)$/i; - if (imageExts.test(trimmed) && !trimmed.includes("\n")) { + const handlePaste = useCallback( + (event: PasteEvent) => { + if (!hasApiKeyRef.current) { + event.preventDefault(); + openApiKeyModal(); + return; + } + + const text = decodePasteBytes(event.bytes); + const trimmed = text.trim(); + const imageExts = /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?)$/i; + if (imageExts.test(trimmed) && !trimmed.includes("\n")) { + event.preventDefault(); + const id = ++pasteCounterRef.current; + const imgNum = ++imageCounterRef.current; + setPasteBlocks((prev) => [...prev, { id, content: trimmed, lines: 1, isImage: true }]); + inputRef.current?.insertText(`[Image ${imgNum}]`); + return; + } + const lineCount = text.split("\n").length; + if (lineCount < 2) return; event.preventDefault(); const id = ++pasteCounterRef.current; - const imgNum = ++imageCounterRef.current; - setPasteBlocks((prev) => [...prev, { id, content: trimmed, lines: 1, isImage: true }]); - inputRef.current?.insertText(`[Image ${imgNum}]`); - return; - } - const lineCount = text.split("\n").length; - if (lineCount < 2) return; - event.preventDefault(); - const id = ++pasteCounterRef.current; - setPasteBlocks((prev) => [...prev, { id, content: text, lines: lineCount }]); - inputRef.current?.insertText(`[Pasted ~${lineCount} lines]`); - }, []); + setPasteBlocks((prev) => [...prev, { id, content: text, lines: lineCount }]); + inputRef.current?.insertText(`[Pasted ~${lineCount} lines]`); + }, + [openApiKeyModal], + ); const handleSubmit = useCallback(() => { const raw = inputRef.current?.plainText || ""; @@ -914,6 +1015,10 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } } if (!message.trim()) return; + if (!hasApiKeyRef.current) { + openApiKeyModal(); + return; + } if (handleCommand(message)) return; if (isProcessingRef.current) { queuedMessagesRef.current.push(message.trim()); @@ -922,7 +1027,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { return; } processMessage(message); - }, [agent, handleCommand, processMessage, pasteBlocks, scrollToBottom]); + }, [agent, handleCommand, openApiKeyModal, processMessage, pasteBlocks, scrollToBottom]); const hasMessages = messages.length > 0 || streamContent || isProcessing; @@ -980,6 +1085,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { showModelPicker={showModelPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel} + showApiKeyModal={showApiKeyModal} onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks} @@ -1009,6 +1115,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { showModelPicker={showModelPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel} + showApiKeyModal={showApiKeyModal} onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks} @@ -1029,6 +1136,16 @@ export function App({ agent, initialMessage, onExit }: AppProps) { )} + {showApiKeyModal && ( + + )} {showSlashMenu && ( void; onPaste: (event: PasteEvent) => void; pasteBlocks: { id: number; content: string; lines: number }[]; @@ -1209,7 +1328,7 @@ function PromptBox({