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:
1 parent
7243b2993c
commit
1b85035829
3 files changed
+319
-331
No files matched your search
+68
-30
@@ -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 = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) + "…";
|
||||
|
||||
Reference in new issue
Block a user