feat: redesign model picker and slash menu modals
- Remove borders, add search filtering with keyboard input - Flat model list without category sections - Blue accent color for current model highlight - Fit-to-content modals with max height cap and scroll - scrollChildIntoView for arrow key navigation - Trim logo height by removing decorative lines - Add accent color to theme Made-with: Cursor
This commit is contained in:
1 parent
4e0850d756
commit
60035f2240
11 files changed
+354
-154
No files matched your search
@@ -0,0 +1 @@
|
||||
plans/
|
||||
@@ -1,13 +1,11 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@vibe-kit/grok-cli",
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.1.88",
|
||||
"@opentui/react": "^0.1.88",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"openai": "^5.10.1",
|
||||
@@ -204,7 +202,7 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
@@ -464,8 +462,6 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.1.88",
|
||||
"@opentui/react": "^0.1.88",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"openai": "^5.10.1",
|
||||
|
||||
+5
-28
@@ -2,7 +2,7 @@ import { GrokClient } from "../grok/client.js";
|
||||
import { TOOLS } from "../grok/tools.js";
|
||||
import { BashTool } from "../tools/bash.js";
|
||||
import { loadCustomInstructions } from "../utils/instructions.js";
|
||||
import type { AgentMode, ChatEntry, Message, StreamChunk, ToolCall, ToolResult } from "../types/index.js";
|
||||
import type { AgentMode, Message, StreamChunk, ToolCall, ToolResult } from "../types/index.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = 400;
|
||||
|
||||
@@ -69,7 +69,6 @@ export class Agent {
|
||||
private client: GrokClient;
|
||||
private bash: BashTool;
|
||||
private messages: Message[] = [];
|
||||
private history: ChatEntry[] = [];
|
||||
private abortController: AbortController | null = null;
|
||||
private maxToolRounds: number;
|
||||
private mode: AgentMode = "agent";
|
||||
@@ -104,14 +103,14 @@ export class Agent {
|
||||
};
|
||||
}
|
||||
|
||||
getHistory(): ChatEntry[] {
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
getCwd(): string {
|
||||
return this.bash.getCwd();
|
||||
}
|
||||
|
||||
generateTitle(userMessage: string): Promise<string> {
|
||||
return this.client.generateTitle(userMessage);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
@@ -121,18 +120,11 @@ export class Agent {
|
||||
role: "system",
|
||||
content: buildSystemPrompt(this.bash.getCwd(), this.mode),
|
||||
}];
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
async *processMessage(userMessage: string): AsyncGenerator<StreamChunk, void, unknown> {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const userEntry: ChatEntry = {
|
||||
type: "user",
|
||||
content: userMessage,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
this.history.push(userEntry);
|
||||
this.messages.push({ role: "user", content: userMessage });
|
||||
|
||||
let toolRounds = 0;
|
||||
@@ -178,13 +170,6 @@ export class Agent {
|
||||
tool_calls: accToolCalls,
|
||||
});
|
||||
|
||||
this.history.push({
|
||||
type: "assistant",
|
||||
content: accContent || "",
|
||||
timestamp: new Date(),
|
||||
toolCalls: accToolCalls,
|
||||
});
|
||||
|
||||
if (!accToolCalls || accToolCalls.length === 0) {
|
||||
break;
|
||||
}
|
||||
@@ -202,14 +187,6 @@ export class Agent {
|
||||
|
||||
yield { type: "tool_result", toolCall: tc, toolResult: result };
|
||||
|
||||
this.history.push({
|
||||
type: "tool_result",
|
||||
content: result.success ? (result.output || "Success") : (result.error || "Error"),
|
||||
timestamp: new Date(),
|
||||
toolCall: tc,
|
||||
toolResult: result,
|
||||
});
|
||||
|
||||
this.messages.push({
|
||||
role: "tool",
|
||||
content: result.success ? (result.output || "Success") : (result.error || "Error"),
|
||||
|
||||
+32
-2
@@ -6,7 +6,6 @@ export interface StreamDelta {
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
reasoning?: string;
|
||||
finishReason?: string | null;
|
||||
}
|
||||
|
||||
export class GrokClient {
|
||||
@@ -60,7 +59,7 @@ export class GrokClient {
|
||||
const choice = chunk.choices?.[0];
|
||||
if (!choice) continue;
|
||||
|
||||
const delta: StreamDelta = { finishReason: choice.finish_reason };
|
||||
const delta: StreamDelta = {};
|
||||
|
||||
if (choice.delta?.content) {
|
||||
delta.content = choice.delta.content;
|
||||
@@ -105,6 +104,37 @@ export class GrokClient {
|
||||
return this.search(query, "x_search");
|
||||
}
|
||||
|
||||
async generateTitle(userMessage: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: "grok-3-mini-fast",
|
||||
temperature: 0.5,
|
||||
max_tokens: 60,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
"You are a title generator. Output ONLY a short title. Nothing else.",
|
||||
"Rules:",
|
||||
"- Single line, ≤50 characters",
|
||||
"- Use the same language as the user message",
|
||||
"- Focus on the main topic or intent",
|
||||
"- Keep technical terms, filenames, numbers exact",
|
||||
"- Remove filler words (the, this, my, a, an)",
|
||||
"- Never use tools or explain anything",
|
||||
"- If the message is a greeting, output something like 'Quick chat'",
|
||||
].join("\n"),
|
||||
},
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
});
|
||||
const title = response.choices?.[0]?.message?.content?.trim().replace(/^["']|["']$/g, "");
|
||||
return title || "New session";
|
||||
} catch {
|
||||
return "New session";
|
||||
}
|
||||
}
|
||||
|
||||
private async search(query: string, toolType: "web_search" | "x_search"): Promise<string> {
|
||||
try {
|
||||
const response = await this.client.responses.create({
|
||||
|
||||
+1
-7
@@ -89,10 +89,4 @@ export function getModelInfo(modelId: string): ModelInfo | undefined {
|
||||
|
||||
export function getModelIds(): string[] {
|
||||
return MODELS.map((m) => m.id);
|
||||
}
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
if (price === 0) return "Free";
|
||||
if (price < 1) return `$${price.toFixed(2)}`;
|
||||
return `$${price}`;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -35,10 +35,12 @@ async function startInteractive(
|
||||
const agent = new Agent(apiKey, baseURL, model, maxToolRounds);
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true,
|
||||
exitOnCtrlC: false,
|
||||
});
|
||||
|
||||
createRoot(renderer).render(createElement(App, { agent, initialMessage }));
|
||||
const onExit = () => { renderer.destroy(); process.exit(0); };
|
||||
|
||||
createRoot(renderer).render(createElement(App, { agent, initialMessage, onExit }));
|
||||
}
|
||||
|
||||
async function runHeadless(
|
||||
|
||||
+3
-4
@@ -34,17 +34,16 @@ export interface ToolDefinition {
|
||||
}
|
||||
|
||||
export interface ChatEntry {
|
||||
type: "user" | "assistant" | "tool_call" | "tool_result" | "search_result";
|
||||
type: "user" | "assistant" | "tool_call" | "tool_result";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCall?: ToolCall;
|
||||
toolResult?: ToolResult;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
type: "content" | "tool_calls" | "tool_result" | "search_result" | "done" | "error" | "reasoning";
|
||||
type: "content" | "tool_calls" | "tool_result" | "done" | "error" | "reasoning";
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCall?: ToolCall;
|
||||
@@ -66,5 +65,5 @@ export type AgentMode = "agent" | "plan" | "ask";
|
||||
export const MODES: { id: AgentMode; label: string; color: string }[] = [
|
||||
{ id: "agent", label: "Agent", color: "#5c9cf5" },
|
||||
{ id: "plan", label: "Plan", color: "#e5c07b" },
|
||||
{ id: "ask", label: "Ask", color: "#f5a742" },
|
||||
{ id: "ask", label: "Ask", color: "#22c55e" },
|
||||
];
|
||||
+305
-99
@@ -1,13 +1,24 @@
|
||||
import os from "os";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import type { InputRenderable, ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ScrollBoxRenderable, TextareaRenderable, KeyBinding } from "@opentui/core";
|
||||
import { decodePasteBytes, PasteEvent } from "@opentui/core";
|
||||
import type { Agent } from "../agent/agent.js";
|
||||
import type { ChatEntry, ToolCall, AgentMode } from "../types/index.js";
|
||||
import type { ChatEntry, ToolCall, AgentMode, ModelInfo } from "../types/index.js";
|
||||
import { MODES } from "../types/index.js";
|
||||
import { getModelInfo, MODEL_GROUPS, MODELS } from "../grok/models.js";
|
||||
import { getModelInfo, MODELS } from "../grok/models.js";
|
||||
import { saveProjectSettings } from "../utils/settings.js";
|
||||
import { dark, type Theme } from "./theme.js";
|
||||
|
||||
const GROK_LOGO = [
|
||||
" █▃▆▃▐▃▆▃=. <▆█",
|
||||
" ▅▆< .@▆# <▆█",
|
||||
"@▆░ I▀▅▌▌▌ @▆▐▌▌▃▆! <▆█ ~▆▀",
|
||||
"▒▆░ .*▆▆▆▆▆ _▆▁ ▒▆= i▅▄I<▆█|▁▂/",
|
||||
"+▆▐: I▁▆ _▆▁ ▆▆= ▅▂\\<▆▓|▃▄l",
|
||||
" █▆▃l....▆▆! _▆▁ ,▆▀i .█▆▌ <▆█ ▃▆/",
|
||||
].join("\n");
|
||||
|
||||
const SPLIT = {
|
||||
topLeft: "", bottomLeft: "", vertical: "┃", topRight: "",
|
||||
bottomRight: "", horizontal: " ", bottomT: "", topT: "",
|
||||
@@ -20,9 +31,25 @@ const EMPTY = {
|
||||
cross: "", leftT: "", rightT: "",
|
||||
};
|
||||
|
||||
interface AppProps { agent: Agent; initialMessage?: string }
|
||||
interface SlashMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function App({ agent, initialMessage }: AppProps) {
|
||||
const SLASH_MENU_ITEMS: SlashMenuItem[] = [
|
||||
{ id: "exit", label: "exit", description: "Quit the CLI" },
|
||||
{ id: "help", label: "help", description: "Show available commands" },
|
||||
{ id: "mcps", label: "mcps", description: "Manage MCP servers" },
|
||||
{ id: "models", label: "models", description: "Select a model" },
|
||||
{ id: "new", label: "new session", description: "Start a new session" },
|
||||
{ id: "review", label: "review", description: "Review recent changes" },
|
||||
{ id: "skills", label: "skills", description: "Manage skills" },
|
||||
];
|
||||
|
||||
interface AppProps { agent: Agent; initialMessage?: string; onExit?: () => void }
|
||||
|
||||
export function App({ agent, initialMessage, onExit }: AppProps) {
|
||||
const t = dark;
|
||||
const [messages, setMessages] = useState<ChatEntry[]>([]);
|
||||
const [streamContent, setStreamContent] = useState("");
|
||||
@@ -32,9 +59,16 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
const [mode, setModeState] = useState<AgentMode>(agent.getMode());
|
||||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||||
const [modelPickerIndex, setModelPickerIndex] = useState(0);
|
||||
const [modelSearchQuery, setModelSearchQuery] = useState("");
|
||||
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||
const [toolCallCount, setToolCallCount] = useState(0);
|
||||
const inputRef = useRef<InputRenderable>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState<string | null>(null);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [slashMenuIndex, setSlashMenuIndex] = useState(0);
|
||||
const [slashSearchQuery, setSlashSearchQuery] = useState("");
|
||||
const [pasteBlocks, setPasteBlocks] = useState<{ id: number; content: string; lines: number; isImage?: boolean }[]>([]);
|
||||
const imageCounterRef = useRef(0);
|
||||
const pasteCounterRef = useRef(0);
|
||||
const inputRef = useRef<TextareaRenderable>(null);
|
||||
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
||||
const { width, height } = useTerminalDimensions();
|
||||
const processedInitial = useRef(false);
|
||||
@@ -50,6 +84,19 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
const modeInfo = MODES.find((m) => m.id === mode)!;
|
||||
const modelInfo = getModelInfo(model);
|
||||
const flatModels = MODELS.map((m) => m.id);
|
||||
const filteredModels = modelSearchQuery
|
||||
? MODELS.filter((m) =>
|
||||
m.name.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
|
||||
m.id.toLowerCase().includes(modelSearchQuery.toLowerCase())
|
||||
)
|
||||
: MODELS;
|
||||
const filteredModelIds = filteredModels.map((m) => m.id);
|
||||
const filteredSlashItems = slashSearchQuery
|
||||
? SLASH_MENU_ITEMS.filter((item) =>
|
||||
item.label.toLowerCase().includes(slashSearchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(slashSearchQuery.toLowerCase())
|
||||
)
|
||||
: SLASH_MENU_ITEMS;
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
try { scrollRef.current?.scrollTo(scrollRef.current?.scrollHeight ?? 99999); } catch { /* */ }
|
||||
@@ -58,7 +105,8 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
const processMessage = useCallback(async (text: string) => {
|
||||
if (!text.trim() || isProcessing) return;
|
||||
setIsProcessing(true); setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); contentAccRef.current = "";
|
||||
setToolCallCount(0); startTimeRef.current = Date.now();
|
||||
startTimeRef.current = Date.now();
|
||||
if (!sessionTitle) agent.generateTitle(text.trim()).then(setSessionTitle).catch(() => {});
|
||||
setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date() }]);
|
||||
setTimeout(scrollToBottom, 50);
|
||||
try {
|
||||
@@ -80,7 +128,6 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
contentAccRef.current = ""; setStreamContent("");
|
||||
}
|
||||
setActiveToolCalls(chunk.toolCalls);
|
||||
setToolCallCount((c) => c + chunk.toolCalls!.length);
|
||||
}
|
||||
break;
|
||||
case "tool_result":
|
||||
@@ -119,35 +166,137 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
|
||||
const handleCommand = useCallback((cmd: string): boolean => {
|
||||
const c = cmd.trim().toLowerCase();
|
||||
if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); return true; }
|
||||
if (c === "/model" || c === "/models") { setShowModelPicker(true); setModelPickerIndex(Math.max(0, flatModels.indexOf(model))); return true; }
|
||||
if (c === "/quit" || c === "/exit" || c === "/q") { process.exit(0); }
|
||||
if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); imageCounterRef.current = 0; return true; }
|
||||
if (c === "/model" || c === "/models") { setShowModelPicker(true); setModelPickerIndex(0); setModelSearchQuery(""); return true; }
|
||||
if (c === "/quit" || c === "/exit" || c === "/q") { onExit ? onExit() : process.exit(0); }
|
||||
return false;
|
||||
}, [agent, model, flatModels]);
|
||||
|
||||
const handleSlashMenuSelect = useCallback((item: SlashMenuItem) => {
|
||||
setShowSlashMenu(false);
|
||||
inputRef.current?.clear();
|
||||
switch (item.id) {
|
||||
case "new":
|
||||
agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]);
|
||||
break;
|
||||
case "models":
|
||||
setShowModelPicker(true); setModelPickerIndex(0); setModelSearchQuery("");
|
||||
break;
|
||||
case "exit":
|
||||
onExit ? onExit() : process.exit(0);
|
||||
break;
|
||||
case "help":
|
||||
setMessages((p) => [...p, { type: "assistant", content: SLASH_MENU_ITEMS.map((i) => `/${i.label} — ${i.description}`).join("\n"), timestamp: new Date() }]);
|
||||
break;
|
||||
case "skills":
|
||||
setMessages((p) => [...p, { type: "assistant", content: "Skills management coming soon.", timestamp: new Date() }]);
|
||||
break;
|
||||
case "mcps":
|
||||
setMessages((p) => [...p, { type: "assistant", content: "MCP server management coming soon.", timestamp: new Date() }]);
|
||||
break;
|
||||
case "review":
|
||||
setMessages((p) => [...p, { type: "assistant", content: "Review feature coming soon.", timestamp: new Date() }]);
|
||||
break;
|
||||
}
|
||||
}, [agent, flatModels, model]);
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (showModelPicker) {
|
||||
if (key.name === "escape") { setShowModelPicker(false); return; }
|
||||
if (key.name === "up") { setModelPickerIndex((i) => Math.max(0, i - 1)); return; }
|
||||
if (key.name === "down") { setModelPickerIndex((i) => Math.min(flatModels.length - 1, i + 1)); 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 sel = flatModels[modelPickerIndex];
|
||||
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 }); }
|
||||
setShowModelPicker(false); return;
|
||||
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 (isProcessing && key.name === "escape") { 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; }
|
||||
});
|
||||
|
||||
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")) {
|
||||
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]`);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const v = inputRef.current?.value || "";
|
||||
if (!v.trim()) return;
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
if (handleCommand(v)) return;
|
||||
processMessage(v);
|
||||
}, [handleCommand, processMessage]);
|
||||
const raw = inputRef.current?.plainText || "";
|
||||
if (!raw.trim() && pasteBlocks.length === 0) return;
|
||||
inputRef.current?.clear();
|
||||
let message = raw;
|
||||
const blocks = [...pasteBlocks];
|
||||
let imgIdx = 0;
|
||||
setPasteBlocks([]);
|
||||
for (const block of blocks) {
|
||||
if (block.isImage) {
|
||||
imgIdx++;
|
||||
message = message.replace(`[Image ${imgIdx}]`, block.content);
|
||||
} else {
|
||||
message = message.replace(`[Pasted ~${block.lines} lines]`, block.content);
|
||||
}
|
||||
}
|
||||
if (!message.trim()) return;
|
||||
if (handleCommand(message)) return;
|
||||
processMessage(message);
|
||||
}, [handleCommand, processMessage, pasteBlocks]);
|
||||
|
||||
const hasMessages = messages.length > 0 || streamContent || isProcessing;
|
||||
|
||||
@@ -156,7 +305,7 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
{hasMessages ? (
|
||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
{/* Session header — ┃ left-border panel like OpenCode's Header */}
|
||||
<SessionHeader t={t} model={model} modelInfo={modelInfo} modeInfo={modeInfo} />
|
||||
<SessionHeader t={t} modeInfo={modeInfo} sessionTitle={sessionTitle} />
|
||||
{/* Scrollable messages */}
|
||||
<scrollbox ref={scrollRef} flexGrow={1} stickyScroll={true} stickyStart={"bottom" as any}>
|
||||
{messages.map((msg, i) => (
|
||||
@@ -185,8 +334,9 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
</scrollbox>
|
||||
{/* Prompt */}
|
||||
<box flexShrink={0}>
|
||||
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker}
|
||||
onSubmit={handleSubmit} mode={mode} modeInfo={modeInfo} model={model} modelInfo={modelInfo} />
|
||||
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu}
|
||||
onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks}
|
||||
modeInfo={modeInfo} model={model} modelInfo={modelInfo} />
|
||||
</box>
|
||||
</box>
|
||||
) : (
|
||||
@@ -195,41 +345,35 @@ export function App({ agent, initialMessage }: AppProps) {
|
||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box flexShrink={0} alignItems="center">
|
||||
<text fg={t.primary}><b>{" ✦ G R O K"}</b></text>
|
||||
<box height={1} />
|
||||
<text fg={t.textMuted}>{"AI coding agent for the terminal"}</text>
|
||||
<text fg={t.text}>{GROK_LOGO}</text>
|
||||
</box>
|
||||
<box height={3} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} flexShrink={0}>
|
||||
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker}
|
||||
onSubmit={handleSubmit} mode={mode} modeInfo={modeInfo} model={model} modelInfo={modelInfo}
|
||||
placeholder={'Ask anything... "Fix broken tests"'} />
|
||||
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu}
|
||||
onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks}
|
||||
modeInfo={modeInfo} model={model} modelInfo={modelInfo}
|
||||
placeholder={"What are we building?"} />
|
||||
</box>
|
||||
<box height={2} minHeight={0} flexShrink={1} />
|
||||
<box flexDirection="row" gap={3}>
|
||||
<text fg={t.textDim}>{"tab"}<span style={{ fg: t.textMuted }}>{" cycle modes"}</span></text>
|
||||
<text fg={t.textDim}>{"/model"}<span style={{ fg: t.textMuted }}>{" switch"}</span></text>
|
||||
<text fg={t.textDim}>{"/quit"}<span style={{ fg: t.textMuted }}>{" exit"}</span></text>
|
||||
</box>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={t.textDim}>{agent.getCwd()}</text>
|
||||
<text fg={t.textDim}>{agent.getCwd().replace(os.homedir(), "~")}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={t.textDim}>{"grok-cli v1.0"}</text>
|
||||
<text fg={t.textDim}>{"v1.0.0"}</text>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
{showModelPicker && <ModelPickerModal t={t} currentModel={model} selectedIndex={modelPickerIndex} width={width} height={height} />}
|
||||
{showSlashMenu && <SlashMenuModal t={t} selectedIndex={slashMenuIndex} width={width} height={height} searchQuery={slashSearchQuery} filteredItems={filteredSlashItems} />}
|
||||
{showModelPicker && <ModelPickerModal t={t} currentModel={model} selectedIndex={modelPickerIndex} width={width} height={height} searchQuery={modelSearchQuery} filteredModels={filteredModels} />}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Session Header ──────────────────────────────────────────── */
|
||||
|
||||
function SessionHeader({ t, model, modelInfo, modeInfo }: {
|
||||
t: Theme; model: string; modelInfo: ReturnType<typeof getModelInfo>;
|
||||
modeInfo: typeof MODES[number];
|
||||
function SessionHeader({ t, modeInfo, sessionTitle }: {
|
||||
t: Theme; modeInfo: typeof MODES[number]; sessionTitle: string | null;
|
||||
}) {
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
@@ -238,7 +382,10 @@ function SessionHeader({ t, model, modelInfo, modeInfo }: {
|
||||
border={["left"]} customBorderChars={SPLIT} borderColor={t.border}
|
||||
backgroundColor={t.backgroundPanel}
|
||||
>
|
||||
<text fg={t.text}><b>{"# "}{modelInfo?.name || model}</b></text>
|
||||
<text>
|
||||
<span style={{ fg: modeInfo.color }}><b>{modeInfo.label}</b></span>
|
||||
{sessionTitle ? <span style={{ fg: t.text }}><b>{": "}{sessionTitle}</b></span> : null}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
@@ -246,40 +393,48 @@ function SessionHeader({ t, model, modelInfo, modeInfo }: {
|
||||
|
||||
/* ── Prompt Box ──────────────────────────────────────────────── */
|
||||
|
||||
function PromptBox({ t, inputRef, isProcessing, showModelPicker, onSubmit, mode, modeInfo, model, modelInfo, placeholder }: {
|
||||
t: Theme; inputRef: React.RefObject<InputRenderable | null>;
|
||||
isProcessing: boolean; showModelPicker: boolean; onSubmit: () => void;
|
||||
mode: AgentMode; modeInfo: typeof MODES[number]; model: string;
|
||||
const TEXTAREA_KEYBINDINGS: KeyBinding[] = [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", shift: true, action: "newline" },
|
||||
];
|
||||
|
||||
function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu, onSubmit, onPaste, pasteBlocks, modeInfo, model, modelInfo, placeholder }: {
|
||||
t: Theme; inputRef: React.RefObject<TextareaRenderable | null>;
|
||||
isProcessing: boolean; showModelPicker: boolean; showSlashMenu: boolean; onSubmit: () => void;
|
||||
onPaste: (event: PasteEvent) => void; pasteBlocks: { id: number; content: string; lines: number }[];
|
||||
modeInfo: typeof MODES[number]; model: string;
|
||||
modelInfo: ReturnType<typeof getModelInfo>; placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<box>
|
||||
<box border={["left"]} customBorderChars={SPLIT_END} borderColor={modeInfo.color}>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} backgroundColor={t.backgroundElement} flexShrink={0}>
|
||||
<input
|
||||
ref={inputRef} focused={!isProcessing && !showModelPicker}
|
||||
<box>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={2} backgroundColor={t.backgroundElement} flexShrink={0}>
|
||||
<textarea
|
||||
ref={inputRef} focused={!isProcessing && !showModelPicker && !showSlashMenu}
|
||||
placeholder={isProcessing ? "Working... (esc to stop)" : (placeholder || "Message Grok...")}
|
||||
textColor={t.text} backgroundColor={t.backgroundElement} placeholderColor={t.textMuted}
|
||||
minHeight={1} maxHeight={10} wrapMode="word"
|
||||
keyBindings={TEXTAREA_KEYBINDINGS}
|
||||
onSubmit={onSubmit as any}
|
||||
onPaste={onPaste as any}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={modeInfo.color}><b>{modeInfo.label}</b>{" "}</text>
|
||||
<text fg={t.text}>{modelInfo?.name || model}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<box height={1} border={["left"]} borderColor={modeInfo.color} customBorderChars={{ ...EMPTY, vertical: "╹" }}>
|
||||
<box height={1} border={["bottom"]} borderColor={t.backgroundElement} customBorderChars={{ ...EMPTY, horizontal: "▀" }} />
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={3}>
|
||||
{isProcessing ? (
|
||||
<text fg={t.text}>{"esc "}<span style={{ fg: t.textMuted }}>{"interrupt"}</span></text>
|
||||
) : (
|
||||
<>
|
||||
<text fg={t.text}>{"tab "}<span style={{ fg: t.textMuted }}>{"modes"}</span></text>
|
||||
<text fg={t.text}>{"ctrl+p "}<span style={{ fg: t.textMuted }}>{"commands"}</span></text>
|
||||
</>
|
||||
)}
|
||||
<box flexDirection="row" justifyContent="space-between" alignItems="center" backgroundColor={t.backgroundPanel} paddingLeft={2} paddingRight={2} border={["top", "bottom"]} borderColor={t.backgroundPanel} customBorderChars={EMPTY}>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={modeInfo.color}><b>{modeInfo.label}</b>{" "}</text>
|
||||
<text fg={t.text}>{modelInfo?.name || model}</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={3} alignItems="center">
|
||||
{isProcessing ? (
|
||||
<text fg={t.text}>{"esc "}<span style={{ fg: t.textMuted }}>{"interrupt"}</span></text>
|
||||
) : (
|
||||
<>
|
||||
<text fg={t.text}>{"shift+enter "}<span style={{ fg: t.textMuted }}>{"new line"}</span></text>
|
||||
<text fg={t.text}>{"tab "}<span style={{ fg: t.textMuted }}>{"modes"}</span></text>
|
||||
</>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
@@ -376,48 +531,99 @@ function InlineTool({ t, icon, pending, children }: { t: Theme; icon: string; pe
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Model Picker ────────────────────────────────────────────── */
|
||||
/* ── Slash Menu ──────────────────────────────────────────────── */
|
||||
|
||||
function ModelPickerModal({ t, currentModel, selectedIndex, width, height }: {
|
||||
t: Theme; currentModel: string; selectedIndex: number; width: number; height: number;
|
||||
function SlashMenuModal({ t, selectedIndex, width, height, searchQuery, filteredItems }: {
|
||||
t: Theme; selectedIndex: number; width: number; height: number;
|
||||
searchQuery: string; filteredItems: SlashMenuItem[];
|
||||
}) {
|
||||
let flatIdx = 0;
|
||||
const listRef = useRef<ScrollBoxRenderable>(null);
|
||||
useEffect(() => {
|
||||
const item = filteredItems[selectedIndex];
|
||||
if (item) listRef.current?.scrollChildIntoView(`slash-${item.id}`);
|
||||
}, [selectedIndex, filteredItems]);
|
||||
|
||||
const itemCount = Math.max(filteredItems.length, 1);
|
||||
const contentHeight = itemCount + 5;
|
||||
const maxH = Math.floor(height * 0.6);
|
||||
const panelHeight = Math.min(contentHeight, maxH);
|
||||
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={Math.max(2, Math.floor(height / 6))}
|
||||
alignItems="center" paddingTop={top}
|
||||
backgroundColor={"#000000cc" as any}>
|
||||
<box width={Math.min(60, width - 6)} backgroundColor={t.backgroundPanel}
|
||||
paddingTop={1} paddingBottom={1} maxHeight={Math.floor(height * 0.7)}
|
||||
border borderStyle="single" borderColor={t.border}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<box width={Math.min(50, width - 6)} 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>{"Commands"}</b></text>
|
||||
<text fg={t.textMuted}>{"esc"}</text>
|
||||
</box>
|
||||
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<text fg={t.text}>{searchQuery || <span style={{ fg: t.textMuted }}>{"Search..."}</span>}</text>
|
||||
</box>
|
||||
<scrollbox ref={listRef} flexGrow={1}>
|
||||
{filteredItems.map((item, idx) => (
|
||||
<box key={item.id} id={`slash-${item.id}`} backgroundColor={idx === selectedIndex ? t.selectedBg : undefined} paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={idx === selectedIndex ? t.selected : t.text}>{"/"}{item.label}</text>
|
||||
<text fg={t.textMuted}>{item.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<box paddingLeft={2}><text fg={t.textMuted}>{"No commands match your search"}</text></box>
|
||||
)}
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Model Picker ────────────────────────────────────────────── */
|
||||
|
||||
function ModelPickerModal({ t, currentModel, selectedIndex, width, height, searchQuery, filteredModels }: {
|
||||
t: Theme; currentModel: string; selectedIndex: number; width: number; height: number;
|
||||
searchQuery: string; filteredModels: ModelInfo[];
|
||||
}) {
|
||||
const listRef = useRef<ScrollBoxRenderable>(null);
|
||||
useEffect(() => {
|
||||
const m = filteredModels[selectedIndex];
|
||||
if (m) listRef.current?.scrollChildIntoView(`model-${m.id}`);
|
||||
}, [selectedIndex, filteredModels]);
|
||||
|
||||
const itemCount = Math.max(filteredModels.length, 1);
|
||||
const contentHeight = itemCount + 5;
|
||||
const maxH = Math.floor(height * 0.6);
|
||||
const panelHeight = Math.min(contentHeight, maxH);
|
||||
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={"#000000cc" as any}>
|
||||
<box width={Math.min(60, width - 6)} 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>{"Select model"}</b></text>
|
||||
<text fg={t.textMuted}>{"esc"}</text>
|
||||
</box>
|
||||
<scrollbox flexGrow={1} paddingTop={1}>
|
||||
{MODEL_GROUPS.map((group) => {
|
||||
const items = group.models.map((mid) => {
|
||||
const info = getModelInfo(mid);
|
||||
const idx = flatIdx++;
|
||||
return { mid, info, idx, selected: idx === selectedIndex, current: mid === currentModel };
|
||||
});
|
||||
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<text fg={t.text}>{searchQuery || <span style={{ fg: t.textMuted }}>{"Search..."}</span>}</text>
|
||||
</box>
|
||||
<scrollbox ref={listRef} flexGrow={1}>
|
||||
{filteredModels.map((m, idx) => {
|
||||
const selected = idx === selectedIndex;
|
||||
const current = m.id === currentModel;
|
||||
return (
|
||||
<box key={group.category}>
|
||||
<box paddingLeft={2} paddingTop={1}><text fg={t.textMuted}>{group.category}</text></box>
|
||||
{items.map(({ mid, info, selected, current }) => (
|
||||
<box key={mid} backgroundColor={selected ? t.selectedBg : undefined} paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={selected ? t.selected : t.text}>
|
||||
{current ? "● " : " "}{info?.name || mid}
|
||||
</text>
|
||||
{info && info.inputPrice < 1 && (
|
||||
<text fg={t.textMuted}>{`$${info.inputPrice}/$${info.outputPrice}`}</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
))}
|
||||
<box key={m.id} id={`model-${m.id}`} backgroundColor={selected ? t.selectedBg : undefined} paddingLeft={2} paddingRight={2}>
|
||||
<text fg={current ? t.accent : selected ? t.selected : t.text}>
|
||||
{m.name}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{filteredModels.length === 0 && (
|
||||
<box paddingLeft={2}><text fg={t.textMuted}>{"No models match your search"}</text></box>
|
||||
)}
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const dark = {
|
||||
textMuted: "#666666",
|
||||
textDim: "#444444",
|
||||
primary: "#ffffff",
|
||||
accent: "#5c9cf5",
|
||||
selected: "#ffffff",
|
||||
selectedBg: "#2a2a2a",
|
||||
} as const;
|
||||
|
||||
@@ -77,9 +77,4 @@ export function getCurrentModel(): string {
|
||||
if (project.model) return project.model;
|
||||
const user = loadUserSettings();
|
||||
return user.defaultModel || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
export function getAvailableModels(): string[] {
|
||||
const user = loadUserSettings();
|
||||
return user.models || getModelIds();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user