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
This commit is contained in:
1 parent
b64887d2f5
commit
d8a82c31e2
5 files changed
+491
-248
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
+44
-8
@@ -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<string> {
|
||||
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<ToolResult> {
|
||||
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 {
|
||||
|
||||
+10
-6
@@ -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<string, string | undefined>) {
|
||||
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<string, string | undefined>) {
|
||||
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,
|
||||
|
||||
+425
-221
@@ -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<ChatEntry[]>(() => 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<ToolCall[]>([]);
|
||||
const [sessionTitle, setSessionTitle] = useState<string | null>(() => agent.getSessionTitle());
|
||||
const [sessionId, setSessionId] = useState<string | null>(() => agent.getSessionId());
|
||||
const [showApiKeyModal, setShowApiKeyModal] = useState(() => !initialHasApiKey);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(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<PlanQuestionsState>(initialPlanQuestionsState());
|
||||
const imageCounterRef = useRef(0);
|
||||
const pasteCounterRef = useRef(0);
|
||||
const apiKeyInputRef = useRef<TextareaRenderable>(null);
|
||||
const inputRef = useRef<TextareaRenderable>(null);
|
||||
const scrollRef = useRef<ScrollBoxRenderable>(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<string[]>([]);
|
||||
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
|
||||
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) {
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
{showApiKeyModal && (
|
||||
<ApiKeyModal
|
||||
t={t}
|
||||
width={width}
|
||||
height={height}
|
||||
inputRef={apiKeyInputRef}
|
||||
error={apiKeyError}
|
||||
onSubmit={submitApiKey}
|
||||
/>
|
||||
)}
|
||||
{showSlashMenu && (
|
||||
<SlashMenuModal
|
||||
t={t}
|
||||
@@ -1130,6 +1247,7 @@ function PromptBox({
|
||||
showModelPicker,
|
||||
showSlashMenu,
|
||||
showPlanQuestions,
|
||||
showApiKeyModal,
|
||||
onSubmit,
|
||||
onPaste,
|
||||
pasteBlocks: _pasteBlocks,
|
||||
@@ -1147,6 +1265,7 @@ function PromptBox({
|
||||
showModelPicker: boolean;
|
||||
showSlashMenu: boolean;
|
||||
showPlanQuestions: boolean;
|
||||
showApiKeyModal: boolean;
|
||||
onSubmit: () => void;
|
||||
onPaste: (event: PasteEvent) => void;
|
||||
pasteBlocks: { id: number; content: string; lines: number }[];
|
||||
@@ -1209,7 +1328,7 @@ function PromptBox({
|
||||
<box flexGrow={1}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
focused={!showModelPicker && !showSlashMenu && !showPlanQuestions}
|
||||
focused={!showModelPicker && !showSlashMenu && !showPlanQuestions && !showApiKeyModal}
|
||||
placeholder={isProcessing ? "Queue a follow-up... (esc to interrupt)" : placeholder || "Message Grok..."}
|
||||
textColor={t.text}
|
||||
backgroundColor={t.backgroundElement}
|
||||
@@ -1267,6 +1386,81 @@ function PromptBox({
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyModal({
|
||||
t,
|
||||
width,
|
||||
height,
|
||||
inputRef,
|
||||
error,
|
||||
onSubmit,
|
||||
}: {
|
||||
t: Theme;
|
||||
width: number;
|
||||
height: number;
|
||||
inputRef: React.RefObject<TextareaRenderable | null>;
|
||||
error: string | null;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const overlayBg = "#000000cc" as string;
|
||||
const panelWidth = Math.min(68, width - 6);
|
||||
const panelHeight = 11;
|
||||
const top = Math.max(2, Math.floor((height - panelHeight) / 2));
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={width}
|
||||
height={height}
|
||||
alignItems="center"
|
||||
paddingTop={top}
|
||||
backgroundColor={overlayBg}
|
||||
>
|
||||
<box width={panelWidth} height={panelHeight} backgroundColor={t.backgroundPanel} paddingTop={1} paddingBottom={1}>
|
||||
<box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text fg={t.primary}>
|
||||
<b>{"Add API key"}</b>
|
||||
</text>
|
||||
<text fg={t.textMuted}>{"esc"}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
|
||||
<text fg={t.text}>{"Paste your xAI API key to unlock chat. You can hide this prompt with esc."}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
|
||||
<box backgroundColor={t.backgroundElement} paddingLeft={1} paddingRight={1} width="100%">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
focused={true}
|
||||
placeholder="xai-..."
|
||||
textColor={t.text}
|
||||
backgroundColor={t.backgroundElement}
|
||||
placeholderColor={t.textMuted}
|
||||
minHeight={1}
|
||||
maxHeight={3}
|
||||
wrapMode="word"
|
||||
keyBindings={TEXTAREA_KEYBINDINGS}
|
||||
onSubmit={onSubmit as unknown as () => void}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
|
||||
{error ? (
|
||||
<text fg={t.diffRemovedFg}>{error}</text>
|
||||
) : (
|
||||
<text>
|
||||
<span style={{ fg: t.primary }}>{"enter "}</span>
|
||||
<span style={{ fg: t.textMuted }}>{"save key · "}</span>
|
||||
<span style={{ fg: t.primary }}>{"esc "}</span>
|
||||
<span style={{ fg: t.textMuted }}>{"hide"}</span>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Messages ────────────────────────────────────────────────── */
|
||||
|
||||
function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index: number; t: Theme; modeColor: string }) {
|
||||
@@ -1970,6 +2164,16 @@ function sanitizeContent(raw: string): string {
|
||||
s = s.replace(/\{"success"\s*:\s*(true|false)\s*,\s*"output"\s*:\s*"[\s\S]*$/m, "");
|
||||
return s.trim();
|
||||
}
|
||||
function shouldOpenApiKeyModalForKey(key: {
|
||||
name?: string;
|
||||
sequence?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
}): boolean {
|
||||
if (key.ctrl || key.meta) return false;
|
||||
if (key.name === "return" || key.name === "backspace") return true;
|
||||
return !!(key.sequence && key.sequence.length === 1);
|
||||
}
|
||||
function compactTaskLabel(label: string): string {
|
||||
const words = label.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length <= 3) return label.trim() || "Working";
|
||||
|
||||
+11
-12
@@ -1,13 +1,11 @@
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { DEFAULT_MODEL, getModelIds } from "../grok/models";
|
||||
import { DEFAULT_MODEL } from "../grok/models";
|
||||
|
||||
export interface UserSettings {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
defaultModel?: string;
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
@@ -38,18 +36,19 @@ function writeJson(filePath: string, data: unknown): void {
|
||||
}
|
||||
|
||||
export function loadUserSettings(): UserSettings {
|
||||
const defaults: UserSettings = {
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
models: getModelIds(),
|
||||
};
|
||||
const saved = readJson<UserSettings>(USER_SETTINGS_PATH);
|
||||
return { ...defaults, ...saved };
|
||||
return readJson<UserSettings>(USER_SETTINGS_PATH) || {};
|
||||
}
|
||||
|
||||
export function saveUserSettings(partial: Partial<UserSettings>): void {
|
||||
const current = loadUserSettings();
|
||||
writeJson(USER_SETTINGS_PATH, { ...current, ...partial });
|
||||
const next: UserSettings = {
|
||||
...(current.apiKey ? { apiKey: current.apiKey } : {}),
|
||||
...(current.defaultModel ? { defaultModel: current.defaultModel } : {}),
|
||||
...(partial.apiKey ? { apiKey: partial.apiKey } : {}),
|
||||
...(partial.defaultModel ? { defaultModel: partial.defaultModel } : {}),
|
||||
};
|
||||
|
||||
writeJson(USER_SETTINGS_PATH, next);
|
||||
}
|
||||
|
||||
export function loadProjectSettings(): ProjectSettings {
|
||||
@@ -68,7 +67,7 @@ export function getApiKey(): string | undefined {
|
||||
}
|
||||
|
||||
export function getBaseURL(): string {
|
||||
return process.env.GROK_BASE_URL || loadUserSettings().baseURL || "https://api.x.ai/v1";
|
||||
return process.env.GROK_BASE_URL || "https://api.x.ai/v1";
|
||||
}
|
||||
|
||||
export function getCurrentModel(): string {
|
||||
|
||||
Reference in new issue
Block a user