feat: add Agent/Plan/Ask mode system with tab cycling and OpenCode home screen

- Add AgentMode type (agent/plan/ask) with per-mode system prompts
- Agent mode: full tool access, executes changes directly
- Plan mode: read-only tools, creates implementation plans without modifying files
- Ask mode: read-only tools, answers questions with context
- Tab key cycles between modes (visible in prompt box)
- OpenCode-style block-letter GROK logo on home screen (left=muted, right=bold)
- Prompt box shows mode label + model name + provider (like OpenCode's prompt)
- Shadow/bottom-edge effect below prompt (╹ + ▀ pattern from OpenCode)
- Keyboard hints bar: 'tab modes · ctrl+p commands' (matches OpenCode layout)
- Mode color changes left-border accent on prompt and user messages
- Refactored into HomeView and SessionView components

Co-authored-by: Ismail Pelaseyed <homanp@users.noreply.github.com>
This commit is contained in:
Cursor AgentandIsmail Pelaseyed committed 2026-03-18 07:19:27 +00:00
1 parent 7243b2993c
commit 1b85035829
3 files changed
+319 -331

No files matched your search

+68 -30
View File
@@ -2,42 +2,65 @@ 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 { ChatEntry, Message, StreamChunk, ToolCall, ToolResult } from "../types/index.js";
import type { AgentMode, ChatEntry, Message, StreamChunk, ToolCall, ToolResult } from "../types/index.js";
const MAX_TOOL_ROUNDS = 400;
function buildSystemPrompt(cwd: string): string {
const custom = loadCustomInstructions();
const customSection = custom
? `\n\nCUSTOM INSTRUCTIONS:\n${custom}\n\nFollow the above custom instructions alongside the standard instructions.\n`
: "";
return `You are Grok CLI, a powerful AI coding agent running in the terminal. You operate directly through bash commands.${customSection}
const MODE_PROMPTS: Record<AgentMode, string> = {
agent: `You are Grok CLI in Agent mode — a powerful AI coding agent. You execute tasks directly using tools.
TOOLS:
- bash: Execute any shell command. Use this for EVERYTHING — viewing files (cat, bat, less), editing files (sed, awk, tee, or write with heredocs), searching code (grep, rg, find), git operations, running builds, installing packages, etc.
- search_web: Search the internet for real-time information, documentation, APIs, current events.
- search_x: Search X (Twitter) for posts, discussions, trends, community sentiment.
- bash: Execute any shell command — viewing files, editing (sed/tee/heredocs), searching (grep/rg/find), git, builds, packages.
- search_web: Real-time web search for documentation, APIs, current events.
- search_x: Search X (Twitter) for posts, discussions, trends.
WORKFLOW:
1. Understand what the user wants
2. Use bash to explore, read files, and understand the codebase
3. Make changes using bash commands (sed, tee, heredocs, etc.)
4. Verify your changes by reading the modified files
5. Run any relevant tests or builds to confirm correctness
1. Understand the request
2. Use bash to explore and understand the codebase
3. Make changes using bash commands
4. Verify changes by reading modified files
5. Run tests or builds to confirm correctness
FILE EDITING PATTERNS:
- For small edits: sed -i 's/old/new/g' file
- For creating files: cat > file << 'EOF' ... EOF
- For appending: cat >> file << 'EOF' ... EOF
- For complex edits: Use a combination of sed, awk, or write the entire file with cat/tee
- Always verify edits by reading the file after modification
Be direct. Execute, don't just describe. Show results, not plans.`,
RESPONSE STYLE:
- Be direct and concise
- Show what you're doing, not what you plan to do
- After tool use, give brief confirmation or note issues
- Use markdown formatting in responses
plan: `You are Grok CLI in Plan mode — you analyze and plan but DO NOT execute changes.
TOOLS:
- bash: ONLY use for reading files (cat, head, tail, find, ls, grep) — NEVER modify files.
- search_web: Research documentation and current information.
- search_x: Research discussions and community sentiment.
BEHAVIOR:
- Create a detailed, step-by-step implementation plan
- Identify files that need changes and describe the specific edits
- Highlight potential risks, edge cases, and dependencies
- Suggest a testing strategy
- NEVER create, modify, or delete files — only read and analyze
Format your plan with clear numbered steps and file paths.`,
ask: `You are Grok CLI in Ask mode — you answer questions clearly and thoroughly.
TOOLS:
- bash: ONLY for reading files to provide context (cat, find, ls, grep) — NEVER modify.
- search_web: Look up documentation and current information.
- search_x: Research discussions and trends.
BEHAVIOR:
- Answer the user's question directly and thoroughly
- Use tools to gather context when needed
- Provide code examples when helpful
- NEVER create, modify, or delete files
- Focus on explanation, not execution`,
};
function buildSystemPrompt(cwd: string, mode: AgentMode): string {
const custom = loadCustomInstructions();
const customSection = custom
? `\n\nCUSTOM INSTRUCTIONS:\n${custom}\n\nFollow the above alongside standard instructions.\n`
: "";
return `${MODE_PROMPTS[mode]}${customSection}
Current working directory: ${cwd}`;
}
@@ -49,6 +72,7 @@ export class Agent {
private history: ChatEntry[] = [];
private abortController: AbortController | null = null;
private maxToolRounds: number;
private mode: AgentMode = "agent";
constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number) {
this.client = new GrokClient(apiKey, model, baseURL);
@@ -56,7 +80,7 @@ export class Agent {
this.maxToolRounds = maxToolRounds || MAX_TOOL_ROUNDS;
this.messages.push({
role: "system",
content: buildSystemPrompt(process.cwd()),
content: buildSystemPrompt(process.cwd(), this.mode),
});
}
@@ -68,6 +92,18 @@ export class Agent {
this.client.setModel(model);
}
getMode(): AgentMode {
return this.mode;
}
setMode(mode: AgentMode): void {
this.mode = mode;
this.messages[0] = {
role: "system",
content: buildSystemPrompt(this.bash.getCwd(), mode),
};
}
getHistory(): ChatEntry[] {
return [...this.history];
}
@@ -81,8 +117,10 @@ export class Agent {
}
clearHistory(): void {
const systemMsg = this.messages[0];
this.messages = [systemMsg];
this.messages = [{
role: "system",
content: buildSystemPrompt(this.bash.getCwd(), this.mode),
}];
this.history = [];
}
+8
View File
@@ -60,3 +60,11 @@ export interface ModelInfo {
reasoning: boolean;
description: string;
}
export type AgentMode = "agent" | "plan" | "ask";
export const MODES: { id: AgentMode; label: string; color: string }[] = [
{ id: "agent", label: "Agent", color: "#fab283" },
{ id: "plan", label: "Plan", color: "#9d7cd8" },
{ id: "ask", label: "Ask", color: "#5c9cf5" },
];
+243 -301
View File
@@ -2,25 +2,26 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import type { InputRenderable, ScrollBoxRenderable } from "@opentui/core";
import type { Agent } from "../agent/agent.js";
import type { ChatEntry, ToolCall } from "../types/index.js";
import type { ChatEntry, ToolCall, AgentMode } from "../types/index.js";
import { MODES } from "../types/index.js";
import { getModelInfo } from "../grok/models.js";
import { getAvailableModels, saveProjectSettings } from "../utils/settings.js";
import { dark, type Theme } from "./theme.js";
const SPLIT_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
topLeft: "", bottomLeft: "", vertical: "┃", topRight: "",
bottomRight: "", horizontal: " ", bottomT: "", topT: "",
cross: "", leftT: "", rightT: "",
};
const SPLIT_BORDER_END = {
...SPLIT_BORDER,
bottomLeft: "╹",
};
const LOGO_LEFT = [" ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀▄", "█ __ █__█ █ █ █__█", "▀▀▀▀ █ ▀▀▀▀ ▀ ▀"];
const LOGO_RIGHT = [" ", "█▀▀▀ █ █", "█ █ █", "▀▀▀▀ ▀▀▀▀"];
interface AppProps {
agent: Agent;
initialMessage?: string;
@@ -33,9 +34,9 @@ export function App({ agent, initialMessage }: AppProps) {
const [streamReasoning, setStreamReasoning] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [model, setModel] = useState(agent.getModel());
const [mode, setModeState] = useState<AgentMode>(agent.getMode());
const [showModelPicker, setShowModelPicker] = useState(false);
const [modelPickerIndex, setModelPickerIndex] = useState(0);
const [showHelp, setShowHelp] = useState(false);
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
const inputRef = useRef<InputRenderable>(null);
const scrollRef = useRef<ScrollBoxRenderable>(null);
@@ -43,28 +44,34 @@ export function App({ agent, initialMessage }: AppProps) {
const processedInitial = useRef(false);
const contentAccRef = useRef("");
const setMode = useCallback((m: AgentMode) => {
agent.setMode(m);
setModeState(m);
}, [agent]);
const cycleMode = useCallback(() => {
const idx = MODES.findIndex((m) => m.id === mode);
const next = MODES[(idx + 1) % MODES.length];
setMode(next.id);
}, [mode, setMode]);
const modeInfo = MODES.find((m) => m.id === mode)!;
const modelInfo = getModelInfo(model);
const scrollToBottom = useCallback(() => {
try {
scrollRef.current?.scrollTo(scrollRef.current?.scrollHeight ?? 99999);
} catch {
/* ignore */
}
try { scrollRef.current?.scrollTo(scrollRef.current?.scrollHeight ?? 99999); } catch { /* */ }
}, []);
const processMessage = useCallback(
async (text: string) => {
if (!text.trim() || isProcessing) return;
setIsProcessing(true);
setStreamContent("");
setStreamReasoning("");
setActiveToolCalls([]);
contentAccRef.current = "";
setMessages((prev) => [
...prev,
{ type: "user", content: text.trim(), timestamp: new Date() },
]);
setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date() }]);
setTimeout(scrollToBottom, 50);
try {
@@ -82,10 +89,7 @@ export function App({ agent, initialMessage }: AppProps) {
if (chunk.toolCalls) {
if (contentAccRef.current.trim()) {
const saved = contentAccRef.current;
setMessages((prev) => [
...prev,
{ type: "assistant", content: saved, timestamp: new Date() },
]);
setMessages((prev) => [...prev, { type: "assistant", content: saved, timestamp: new Date() }]);
contentAccRef.current = "";
setStreamContent("");
}
@@ -94,18 +98,11 @@ export function App({ agent, initialMessage }: AppProps) {
break;
case "tool_result":
if (chunk.toolCall && chunk.toolResult) {
setMessages((prev) => [
...prev,
{
type: "tool_result",
content: chunk.toolResult!.success
? chunk.toolResult!.output || "Success"
: chunk.toolResult!.error || "Error",
timestamp: new Date(),
toolCall: chunk.toolCall,
toolResult: chunk.toolResult,
},
]);
setMessages((prev) => [...prev, {
type: "tool_result",
content: chunk.toolResult!.success ? (chunk.toolResult!.output || "Success") : (chunk.toolResult!.error || "Error"),
timestamp: new Date(), toolCall: chunk.toolCall, toolResult: chunk.toolResult,
}]);
setActiveToolCalls([]);
setTimeout(scrollToBottom, 10);
}
@@ -114,8 +111,7 @@ export function App({ agent, initialMessage }: AppProps) {
contentAccRef.current += `\n${chunk.content || "Unknown error"}`;
setStreamContent(contentAccRef.current);
break;
case "done":
break;
case "done": break;
}
}
} catch {
@@ -125,10 +121,7 @@ export function App({ agent, initialMessage }: AppProps) {
if (contentAccRef.current.trim()) {
const final = contentAccRef.current;
setMessages((prev) => [
...prev,
{ type: "assistant", content: final, timestamp: new Date() },
]);
setMessages((prev) => [...prev, { type: "assistant", content: final, timestamp: new Date() }]);
}
contentAccRef.current = "";
@@ -151,7 +144,7 @@ export function App({ agent, initialMessage }: AppProps) {
const handleCommand = useCallback(
(cmd: string): boolean => {
const c = cmd.trim().toLowerCase();
if (c === "/help") { setShowHelp((v) => !v); return true; }
if (c === "/help") { return true; }
if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); return true; }
if (c === "/model" || c === "/models") { setShowModelPicker(true); setModelPickerIndex(0); return true; }
if (c === "/quit" || c === "/exit" || c === "/q") { process.exit(0); }
@@ -174,8 +167,8 @@ export function App({ agent, initialMessage }: AppProps) {
}
return;
}
if (showHelp && key.name === "escape") { setShowHelp(false); return; }
if (isProcessing && key.name === "escape") { agent.abort(); return; }
if (key.name === "tab" && !isProcessing) { cycleMode(); return; }
});
const handleSubmit = useCallback(() => {
@@ -194,161 +187,224 @@ export function App({ agent, initialMessage }: AppProps) {
return (
<box width={width} height={height} backgroundColor={t.background} flexDirection="column">
{/* Main content area */}
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
{hasMessages ? (
<>
<Header t={t} model={model} cwd={agent.getCwd()} isProcessing={isProcessing} />
<scrollbox
ref={scrollRef}
flexGrow={1}
stickyScroll={true}
stickyStart={"bottom" as any}
>
{showHelp && <HelpPanel t={t} />}
{messages.map((msg, i) => (
<MessageView key={i} entry={msg} index={i} t={t} />
))}
{activeToolCalls.length > 0 && activeToolCalls.map((tc, i) => (
<ToolCallPending key={i} tc={tc} t={t} />
))}
{streamReasoning && (
<box
paddingLeft={2}
marginTop={1}
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={t.backgroundElement}
>
<text fg={t.textMuted}>
<i>{"Thinking: "}{truncate(streamReasoning, 300)}</i>
</text>
</box>
)}
{streamContent && (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text fg={t.text}>{streamContent}</text>
</box>
)}
{isProcessing && !streamContent && activeToolCalls.length === 0 && (
<box paddingLeft={3} marginTop={1}>
<text fg={t.textMuted}>{"~ Thinking..."}</text>
</box>
)}
</scrollbox>
<box flexShrink={0}>
<PromptInput
t={t}
inputRef={inputRef}
isProcessing={isProcessing}
showModelPicker={showModelPicker}
onSubmit={handleSubmit}
/>
{hasMessages ? (
<SessionView
t={t} agent={agent} model={model} mode={mode} modeInfo={modeInfo} modelInfo={modelInfo}
messages={messages} streamContent={streamContent} streamReasoning={streamReasoning}
isProcessing={isProcessing} activeToolCalls={activeToolCalls}
scrollRef={scrollRef} inputRef={inputRef} showModelPicker={showModelPicker}
handleSubmit={handleSubmit} scrollToBottom={scrollToBottom}
/>
) : (
<HomeView
t={t} agent={agent} model={model} mode={mode} modeInfo={modeInfo} modelInfo={modelInfo}
inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker}
handleSubmit={handleSubmit}
/>
)}
</box>
);
}
/* ── Home Screen ─────────────────────────────────────────────────── */
function HomeView({ t, agent, model, mode, modeInfo, modelInfo, inputRef, isProcessing, showModelPicker, handleSubmit }: {
t: Theme; agent: Agent; model: string; mode: AgentMode;
modeInfo: typeof MODES[number]; modelInfo: ReturnType<typeof getModelInfo>;
inputRef: React.RefObject<InputRenderable | null>;
isProcessing: boolean; showModelPicker: boolean; handleSubmit: () => void;
}) {
return (
<>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
{/* Logo */}
<box flexShrink={0}>
{LOGO_LEFT.map((line, i) => (
<box key={i} flexDirection="row" gap={1}>
<text fg={t.textMuted}>{line}</text>
<text fg={t.text}><b>{LOGO_RIGHT[i]}</b></text>
</box>
</>
))}
</box>
<box height={1} minHeight={0} flexShrink={1} />
{/* Prompt box */}
<box width="100%" maxWidth={75} paddingTop={1} 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"`}
/>
</box>
{/* Keyboard hints */}
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={2} flexShrink={1}>
<box flexDirection="row" gap={2}>
<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>
</box>
<box flexGrow={1} minHeight={0} />
</box>
{/* Footer */}
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={t.textMuted}>{agent.getCwd()}</text>
<box flexGrow={1} />
<text fg={t.textMuted}>{"v1.0.0"}</text>
</box>
</>
);
}
/* ── Session View ────────────────────────────────────────────────── */
function SessionView({ t, agent, model, mode, modeInfo, modelInfo, messages, streamContent, streamReasoning, isProcessing, activeToolCalls, scrollRef, inputRef, showModelPicker, handleSubmit, scrollToBottom }: {
t: Theme; agent: Agent; model: string; mode: AgentMode;
modeInfo: typeof MODES[number]; modelInfo: ReturnType<typeof getModelInfo>;
messages: ChatEntry[]; streamContent: string; streamReasoning: string;
isProcessing: boolean; activeToolCalls: ToolCall[];
scrollRef: React.RefObject<ScrollBoxRenderable | null>;
inputRef: React.RefObject<InputRenderable | null>;
showModelPicker: boolean; handleSubmit: () => void; scrollToBottom: () => void;
}) {
return (
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Header t={t} model={model} modelInfo={modelInfo} isProcessing={isProcessing} />
<scrollbox ref={scrollRef} flexGrow={1} stickyScroll={true} stickyStart={"bottom" as any}>
{messages.map((msg, i) => (
<MessageView key={i} entry={msg} index={i} t={t} modeColor={modeInfo.color} />
))}
{activeToolCalls.length > 0 && activeToolCalls.map((tc, i) => (
<ToolCallPending key={i} tc={tc} t={t} />
))}
{streamReasoning && (
<box paddingLeft={2} marginTop={1} border={["left"]} customBorderChars={SPLIT_BORDER} borderColor={t.backgroundElement}>
<text fg={t.textMuted}><i>{"Thinking: "}{truncate(streamReasoning, 300)}</i></text>
</box>
)}
{streamContent && (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text fg={t.text}>{streamContent}</text>
</box>
)}
{isProcessing && !streamContent && activeToolCalls.length === 0 && (
<box paddingLeft={3} marginTop={1}>
<text fg={t.textMuted}>{"~ Thinking..."}</text>
</box>
)}
</scrollbox>
<box flexShrink={0}>
<PromptBox
t={t} inputRef={inputRef} isProcessing={isProcessing}
showModelPicker={showModelPicker} onSubmit={handleSubmit}
mode={mode} modeInfo={modeInfo} model={model} modelInfo={modelInfo}
/>
</box>
<Footer t={t} cwd={agent.getCwd()} model={model} isProcessing={isProcessing} />
</box>
);
}
/* ── Prompt Box (OpenCode-style: input + mode/model line below) ── */
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;
modelInfo: ReturnType<typeof getModelInfo>;
placeholder?: string;
}) {
const modelDisplay = modelInfo?.name || model;
const providerDisplay = "xAI";
return (
<box>
<box
border={["left"]}
customBorderChars={SPLIT_BORDER_END}
borderColor={modeInfo.color}
>
<box
paddingLeft={2}
paddingRight={2}
paddingTop={1}
backgroundColor={t.backgroundElement}
flexShrink={0}
>
<input
ref={inputRef}
focused={!isProcessing && !showModelPicker}
placeholder={isProcessing ? "Processing... (Esc to cancel)" : (placeholder || "Ask anything...")}
textColor={t.text}
backgroundColor={t.backgroundElement}
placeholderColor={t.textMuted}
onSubmit={onSubmit as any}
/>
{/* Mode + Model line below input */}
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={modeInfo.color}>{modeInfo.label}{" "}</text>
<text fg={t.text}>{modelDisplay}</text>
<text fg={t.textMuted}>{providerDisplay}</text>
</box>
</box>
</box>
{/* Shadow/bottom edge like OpenCode */}
<box height={1} border={["left"]} borderColor={modeInfo.color} customBorderChars={{ ...SPLIT_BORDER, vertical: "╹" }}>
<box height={1} border={["bottom"]} borderColor={t.backgroundElement} customBorderChars={{ ...SPLIT_BORDER, horizontal: "▀" }} />
</box>
{/* Hints row */}
<box flexDirection="row" justifyContent="flex-end" gap={2}>
{isProcessing ? (
<text fg={t.text}>{"esc "}<span style={{ fg: t.textMuted }}>{"interrupt"}</span></text>
) : (
/* Home screen — centered logo + prompt like OpenCode */
<>
<box flexGrow={1} alignItems="center">
<box flexGrow={1} minHeight={0} />
<box flexShrink={0}>
<text fg={t.primary}>
<b>{" ┏━━┓┏━━┓┏━━┓┏┓┏┓"}</b>
</text>
<text fg={t.primary}>
<b>{" ┃╺━┫┣━┓┃┃╺╸┃┃┗┛┃"}</b>
</text>
<text fg={t.primary}>
<b>{" ┗━━┛┗━━┛┗━━┛┗━━━┛"}</b>
</text>
<text fg={t.textMuted}>{" grok cli"}</text>
</box>
{showHelp && (
<box marginTop={1} width="100%" maxWidth={75}>
<HelpPanel t={t} />
</box>
)}
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} paddingTop={1} flexShrink={0}>
<PromptInput
t={t}
inputRef={inputRef}
isProcessing={isProcessing}
showModelPicker={showModelPicker}
onSubmit={handleSubmit}
/>
</box>
<box height={4} minHeight={0} flexShrink={1} />
<box flexGrow={1} minHeight={0} />
</box>
<Footer t={t} cwd={agent.getCwd()} model={model} />
<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>
{/* Footer when in session */}
{hasMessages && <Footer t={t} cwd={agent.getCwd()} model={model} />}
</box>
);
}
function Header({ t, model, cwd, isProcessing }: { t: Theme; model: string; cwd: string; isProcessing: boolean }) {
const info = getModelInfo(model);
const modelLabel = info ? `${info.name} · ${model}` : model;
/* ── Header ──────────────────────────────────────────────────────── */
function Header({ t, model, modelInfo, isProcessing }: { t: Theme; model: string; modelInfo: ReturnType<typeof getModelInfo>; isProcessing: boolean }) {
const label = modelInfo ? `${modelInfo.name} · ${model}` : model;
return (
<box flexShrink={0}>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={t.border}
backgroundColor={t.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={1}
border={["left"]} customBorderChars={SPLIT_BORDER} borderColor={t.border}
backgroundColor={t.backgroundPanel} flexDirection="row" justifyContent="space-between"
>
<text fg={t.text}>
<b>{"# Grok CLI"}</b>
</text>
<text fg={t.textMuted}>
{modelLabel}{isProcessing ? " ⏳" : ""}
</text>
<text fg={t.text}><b>{"# Grok CLI"}</b></text>
<text fg={t.textMuted}>{label}{isProcessing ? " ⏳" : ""}</text>
</box>
</box>
);
}
function MessageView({ entry, index, t }: { entry: ChatEntry; index: number; t: Theme }) {
/* ── Message Rendering ───────────────────────────────────────────── */
function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index: number; t: Theme; modeColor: string }) {
switch (entry.type) {
case "user":
return (
<box
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={t.primary}
marginTop={index === 0 ? 0 : 1}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={t.backgroundPanel}
flexShrink={0}
>
<box border={["left"]} customBorderChars={SPLIT_BORDER} borderColor={modeColor} marginTop={index === 0 ? 0 : 1}>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={t.backgroundPanel} flexShrink={0}>
<text fg={t.text}>{entry.content}</text>
</box>
</box>
);
case "assistant":
return (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text fg={t.text}>{entry.content}</text>
</box>
);
case "tool_result": {
const name = entry.toolCall?.function.name || "tool";
const success = entry.toolResult?.success ?? true;
@@ -359,17 +415,8 @@ function MessageView({ entry, index, t }: { entry: ChatEntry; index: number; t:
const lines = output.split("\n");
const preview = lines.slice(0, 10).join("\n") + (lines.length > 10 ? "\n…" : "");
return (
<box
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={t.background}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
gap={1}
backgroundColor={t.backgroundPanel}
>
<box border={["left"]} customBorderChars={SPLIT_BORDER} borderColor={t.background}
paddingTop={1} paddingBottom={1} paddingLeft={2} marginTop={1} gap={1} backgroundColor={t.backgroundPanel}>
<text fg={t.textMuted}>{"# "}{getToolTitle(name, args)}</text>
<box gap={1}>
<text fg={t.text}>{"$ "}{args}</text>
@@ -383,13 +430,10 @@ function MessageView({ entry, index, t }: { entry: ChatEntry; index: number; t:
const fg = success ? t.textMuted : t.error;
return (
<box paddingLeft={3} marginTop={0}>
<text fg={fg}>
{icon}{" "}{getToolLabel(name, args, success)}
</text>
<text fg={fg}>{icon}{" "}{getToolLabel(name, args, success)}</text>
</box>
);
}
default:
return <text fg={t.textMuted}>{entry.content}</text>;
}
@@ -399,123 +443,33 @@ function ToolCallPending({ tc, t }: { tc: ToolCall; t: Theme }) {
const args = getToolArgs(tc);
return (
<box paddingLeft={3} marginTop={0}>
<text fg={t.text}>
{"~ "}{tc.function.name === "bash" ? args : `${tc.function.name} ${args}`}
</text>
<text fg={t.text}>{"~ "}{tc.function.name === "bash" ? args : `${tc.function.name} ${args}`}</text>
</box>
);
}
function PromptInput({
t,
inputRef,
isProcessing,
showModelPicker,
onSubmit,
}: {
t: Theme;
inputRef: React.RefObject<InputRenderable | null>;
isProcessing: boolean;
showModelPicker: boolean;
onSubmit: () => void;
}) {
return (
<box
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={isProcessing ? t.warning : t.primary}
backgroundColor={t.backgroundPanel}
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
>
<input
ref={inputRef}
focused={!isProcessing && !showModelPicker}
placeholder={isProcessing ? "Processing... (Esc to cancel)" : "Ask anything..."}
textColor={t.text}
backgroundColor={t.backgroundPanel}
placeholderColor={t.textMuted}
onSubmit={onSubmit as any}
/>
</box>
);
}
/* ── Footer ──────────────────────────────────────────────────────── */
function Footer({ t, cwd, model }: { t: Theme; cwd: string; model: string }) {
function Footer({ t, cwd, model, isProcessing }: { t: Theme; cwd: string; model: string; isProcessing?: boolean }) {
return (
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={t.textMuted}>{cwd}</text>
<box flexGrow={1} />
<text fg={t.textMuted}>{model}</text>
</box>
);
}
function HelpPanel({ t }: { t: Theme }) {
return (
<box
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={t.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
backgroundColor={t.backgroundPanel}
gap={1}
>
<text fg={t.textMuted}>{"# Help"}</text>
<box>
<text fg={t.text}>{"/model"}<span style={{ fg: t.textMuted }}>{" — Switch model"}</span></text>
<text fg={t.text}>{"/clear"}<span style={{ fg: t.textMuted }}>{" — Clear history"}</span></text>
<text fg={t.text}>{"/help"}<span style={{ fg: t.textMuted }}>{" — Toggle help"}</span></text>
<text fg={t.text}>{"/quit"}<span style={{ fg: t.textMuted }}>{" — Exit"}</span></text>
</box>
<box>
<text fg={t.text}>{"Esc"}<span style={{ fg: t.textMuted }}>{" — Cancel / close"}</span></text>
<text fg={t.text}>{"Enter"}<span style={{ fg: t.textMuted }}>{" — Send message"}</span></text>
</box>
</box>
);
}
/* ── Model Picker ────────────────────────────────────────────────── */
function ModelPickerView({
t,
currentModel,
selectedIndex,
width,
height,
}: {
t: Theme;
currentModel: string;
selectedIndex: number;
width: number;
height: number;
function ModelPickerView({ t, currentModel, selectedIndex, width, height }: {
t: Theme; currentModel: string; selectedIndex: number; width: number; height: number;
}) {
const models = getAvailableModels();
return (
<box width={width} height={height} backgroundColor={t.background} flexDirection="column">
<box flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<box
border={["left"]}
customBorderChars={SPLIT_BORDER}
borderColor={t.primary}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={t.backgroundPanel}
gap={1}
flexGrow={1}
>
<box border={["left"]} customBorderChars={SPLIT_BORDER} borderColor={t.primary}
paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={t.backgroundPanel} gap={1} flexGrow={1}>
<text fg={t.text}><b>{"# Switch Model"}</b></text>
<text fg={t.textMuted}>{"↑/↓ select · Enter confirm · Esc cancel"}</text>
<box marginTop={1}>
@@ -526,10 +480,7 @@ function ModelPickerView({
return (
<box key={i} backgroundColor={selected ? t.backgroundElement : undefined}>
<text fg={selected ? t.primary : current ? t.success : t.text}>
{selected ? " ❯ " : " "}
{m}
{current ? " (current)" : ""}
{info ? ` — ${info.description}` : ""}
{selected ? " ❯ " : " "}{m}{current ? " (current)" : ""}{info ? ` — ${info.description}` : ""}
</text>
</box>
);
@@ -544,22 +495,17 @@ function ModelPickerView({
);
}
function getToolIcon(name: string): string {
switch (name) {
case "bash": return "$";
case "search_web": return "◈";
case "search_x": return "◇";
default: return "⚙";
}
}
/* ── Helpers ──────────────────────────────────────────────────────── */
function getToolIcon(name: string): string {
switch (name) { case "bash": return "$"; case "search_web": return "◈"; case "search_x": return "◇"; default: return "⚙"; }
}
function getToolTitle(name: string, args: string): string {
if (name === "bash") return "Shell";
if (name === "search_web") return `Web Search "${args}"`;
if (name === "search_x") return `X Search "${args}"`;
return name;
}
function getToolLabel(name: string, args: string, success: boolean): string {
if (!success) return `${name} failed`;
if (name === "bash") return args;
@@ -567,18 +513,14 @@ function getToolLabel(name: string, args: string, success: boolean): string {
if (name === "search_x") return `X Search "${args}"`;
return `${name} ${args}`;
}
function getToolArgs(tc?: ToolCall): string {
if (!tc) return "";
try {
const args = JSON.parse(tc.function.arguments);
if (tc.function.name === "bash") return args.command || "";
return args.query || "";
} catch {
return "";
}
} catch { return ""; }
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max) + "…";