Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ Grok CLI (`@vibe-kit/grok-cli`) is a single-package TypeScript CLI tool — no d
### Environment

- **Bun** must be installed (not pre-installed on Cloud VMs). The update script handles this.
- `GROK_API_KEY` environment variable is required for API calls. Set it as a secret.
- `GROK_API_KEY` environment variable is required for API calls. Set it as a secret.

## Maintaining this file

Keep this file for knowledge useful to almost every future agent session in this project.
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
Prefer rewriting or pruning existing entries over appending new ones.
When updating this file, preserve this bar for all agents and keep entries concise.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ grok uninstall --keep-config
grok
```

While reviewing the transcript, use `Ctrl+P` and `Ctrl+N` to move to the previous or next user prompt.

### Supported terminals

For the most reliable interactive OpenTUI experience, use a modern terminal emulator. We currently document and recommend:
Expand Down
150 changes: 142 additions & 8 deletions src/ui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ import {
type PlanQuestionsState,
PlanView,
} from "./plan";
import {
deriveUserPromptIndex,
findNearestPrompt,
getPromptNavigationDirection,
type PromptAnchor,
type PromptNavigationDirection,
} from "./prompt-navigation";
import { buildScheduleBrowseRows, ScheduleBrowserModal } from "./schedule-modal";
import { filterSlashMenuItems, SLASH_MENU_ITEMS, type SlashMenuItem } from "./slash-menu";
import {
Expand Down Expand Up @@ -661,6 +668,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const [activePlan, setActivePlan] = useState<Plan | null>(null);
/** Incremented on each successful TUI copy; drives a brief "Copied" banner. */
const [copyFlashId, setCopyFlashId] = useState(0);
const [promptFlashMessageIndex, setPromptFlashMessageIndex] = useState<number | null>(null);
const [expandedMessages, setExpandedMessages] = useState<Set<number>>(() => new Set());
const [activeSubagent, setActiveSubagent] = useState<SubagentStatus | null>(null);
const [pqs, setPqs] = useState<PlanQuestionsState>(initialPlanQuestionsState());
Expand All @@ -669,6 +677,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const apiKeyInputRef = useRef<TextareaRenderable>(null);
const inputRef = useRef<TextareaRenderable>(null);
const scrollRef = useRef<ScrollBoxRenderable>(null);
const promptFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isReviewingPromptsRef = useRef(false);
const { width, height } = useTerminalDimensions();
const processedInitial = useRef(false);
const contentAccRef = useRef("");
Expand Down Expand Up @@ -1093,6 +1103,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
void agent
.getScheduleDaemonStatus()
.then((status) => {
isReviewingPromptsRef.current = false;
setMessages((prev) => [...prev, buildAssistantEntry(formatScheduleDetails(schedule, status))]);
setShowScheduleModal(false);
setScheduleSearchQuery("");
Expand All @@ -1119,6 +1130,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
.then(async (message) => {
const latest = await agent.listSchedules();
setSchedules(latest);
isReviewingPromptsRef.current = false;
setScheduleModalIndex((index) => Math.max(0, Math.min(index, Math.max(0, latest.length - 1))));
setMessages((prev) => [...prev, buildAssistantEntry(message)]);
setTimeout(() => {
Expand Down Expand Up @@ -1375,12 +1387,62 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setScheduleModalIndex((idx) => Math.max(0, Math.min(idx, Math.max(0, scheduleRows.length - 1))));
}, [scheduleRows.length]);

const userPromptIndex = useMemo(() => deriveUserPromptIndex(messages), [messages]);

const setPromptReviewing = useCallback((reviewing: boolean) => {
isReviewingPromptsRef.current = reviewing;
}, []);

const isAtTranscriptBottom = useCallback((scrollBox: ScrollBoxRenderable) => {
const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
return scrollBox.scrollTop >= maxScrollTop;
}, []);

const syncPromptReview = useCallback(() => {
const scrollBox = scrollRef.current;
if (!scrollBox || !isReviewingPromptsRef.current) return;
if (isAtTranscriptBottom(scrollBox)) {
setPromptReviewing(false);
}
}, [isAtTranscriptBottom, setPromptReviewing]);

const schedulePromptReviewSync = useCallback(() => {
if (!isReviewingPromptsRef.current) return;
setTimeout(syncPromptReview, 0);
}, [syncPromptReview]);

const scrollToBottom = useCallback(() => {
try {
scrollRef.current?.scrollTo(scrollRef.current?.scrollHeight ?? 99999);
const scrollBox = scrollRef.current;
if (!scrollBox) return;
if (isReviewingPromptsRef.current) {
if (!isAtTranscriptBottom(scrollBox)) return;
setPromptReviewing(false);
}
Comment thread
cursor[bot] marked this conversation as resolved.
scrollBox.scrollTo(scrollBox.scrollHeight ?? 99999);
} catch {
/* */
}
}, [isAtTranscriptBottom, setPromptReviewing]);

const flashPrompt = useCallback((messageIndex: number) => {
if (promptFlashTimeoutRef.current) {
clearTimeout(promptFlashTimeoutRef.current);
}

setPromptFlashMessageIndex(messageIndex);
promptFlashTimeoutRef.current = setTimeout(() => {
setPromptFlashMessageIndex(null);
promptFlashTimeoutRef.current = null;
}, 600);
}, []);

useEffect(() => {
return () => {
if (promptFlashTimeoutRef.current) {
clearTimeout(promptFlashTimeoutRef.current);
}
};
}, []);

const clearLiveTurnUi = useCallback(() => {
Expand Down Expand Up @@ -1464,7 +1526,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const applyTelegramAssistantPreview = useCallback(
(fullContent: string) => {
const activeTurn = activeTurnRef.current;
if (!activeTurn || activeTurn.kind !== "telegram") return;
if (activeTurn?.kind !== "telegram") return;

activeTurn.latestAssistantText = fullContent;
contentAccRef.current = getUnflushedTelegramAssistantContent(fullContent, activeTurn.flushedAssistantChars);
Expand Down Expand Up @@ -2028,6 +2090,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}, [interruptActiveRun, renderer]);

const resetToNewSession = useCallback(() => {
setPromptReviewing(false);
const snapshot = agent.startNewSession();
setMessages(snapshot?.entries ?? []);
setExpandedMessages(new Set());
Expand All @@ -2041,11 +2104,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
replacePasteBlocks([]);
queuedMessagesRef.current = [];
setQueuedMessages([]);
}, [agent, clearLiveTurnUi, replacePasteBlocks]);
}, [agent, clearLiveTurnUi, replacePasteBlocks, setPromptReviewing]);

const processMessage = useCallback(
async (text: string, displayText?: string) => {
if (!text.trim() || isProcessingRef.current) return;
setPromptReviewing(false);
const runId = ++activeRunIdRef.current;
const isStale = () => activeRunIdRef.current !== runId;
isProcessingRef.current = true;
Expand Down Expand Up @@ -2156,6 +2220,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
finalizeActiveTurn,
scrollToBottom,
sessionTitle,
setPromptReviewing,
showLiveToolCalls,
],
);
Expand Down Expand Up @@ -2521,8 +2586,58 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setBtwState(null);
}, []);

const navigateToPrompt = useCallback(
(direction: PromptNavigationDirection) => {
const scrollBox = scrollRef.current;
if (!scrollBox) return;

const currentScrollTop = scrollBox.scrollTop;
const children = scrollBox.getChildren();
const anchors = userPromptIndex.flatMap<PromptAnchor>((messageIndex) => {
const child = children[messageIndex];
if (!child) return [];

return [
{
messageIndex,
offset: currentScrollTop + child.y - scrollBox.viewport.y,
},
];
});
const target = findNearestPrompt(anchors, currentScrollTop, direction);
if (!target) return;

setPromptReviewing(true);
scrollBox.scrollTo(target.offset);
flashPrompt(target.messageIndex);
},
Comment thread
cursor[bot] marked this conversation as resolved.
[flashPrompt, setPromptReviewing, userPromptIndex],
);

// Intercept the raw control bytes before OpenTUI routes them to the focused
// textarea or scrollbox. This keeps prompt navigation separate from
// composer history and ordinary arrow movement.
useEffect(() => {
const onRawPromptNavigation = (sequence: string) => {
const parsed = parseKeypress(sequence, { useKittyKeyboard: renderer.useKittyKeyboard });
const direction = parsed ? getPromptNavigationDirection(parsed) : null;
if (!direction) return false;

navigateToPrompt(direction);
return true;
};

renderer.prependInputHandler(onRawPromptNavigation);
return () => {
renderer.removeInputHandler(onRawPromptNavigation);
};
}, [navigateToPrompt, renderer]);
Comment thread
cursor[bot] marked this conversation as resolved.

const handleKey = useCallback(
(key: KeyEvent) => {
if (isReviewingPromptsRef.current) {
schedulePromptReviewSync();
}
if (btwState) {
if (isEscapeKey(key) || key.name === "return") {
dismissBtw();
Expand Down Expand Up @@ -3284,6 +3399,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
handlePlanSelect,
handleSlashMenuSelect,
interruptActiveRun,
schedulePromptReviewSync,
isPlanConfirmTab,
isProcessing,
isSinglePlan,
Expand Down Expand Up @@ -3397,6 +3513,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
message = message.replace(getFileMentionToken(block), `@${block.path}`);
}
if (!message.trim()) return;
setPromptReviewing(false);
if (!hasApiKeyRef.current) {
openApiKeyModal();
return;
Expand All @@ -3410,7 +3527,16 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
processMessage(enhancedMessage, displayText);
}, [agent, clearLiveTurnUi, handleCommand, openApiKeyModal, processMessage, replacePasteBlocks, scrollToBottom]);
}, [
agent,
clearLiveTurnUi,
handleCommand,
openApiKeyModal,
processMessage,
replacePasteBlocks,
scrollToBottom,
setPromptReviewing,
]);

const hasMessages = messages.length > 0 || streamContent || isProcessing;

Expand All @@ -3429,8 +3555,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
<SessionHeader t={t} modeInfo={modeInfo} sessionTitle={sessionTitle} sessionId={sessionId} />
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
{/* Scrollable messages */}
{/* biome-ignore lint/suspicious/noExplicitAny: OpenTUI type mismatch for stickyStart */}
<scrollbox ref={scrollRef} flexGrow={1} stickyScroll={true} stickyStart={"bottom" as any}>
<scrollbox
ref={scrollRef}
flexGrow={1}
stickyScroll={true}
stickyStart="bottom"
onMouseScroll={schedulePromptReviewSync}
>
{messages.map((msg, i) => (
<MessageView
key={`${msg.timestamp.getTime()}-${msg.type}-${msg.remoteKey ?? ""}-${msg.content.slice(0, 24)}`}
Expand All @@ -3439,6 +3570,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
t={t}
modeColor={modeInfo.color}
expandedMessages={expandedMessages}
highlightedPrompt={promptFlashMessageIndex === i}
/>
))}
{liveTurnSourceLabel && (activeToolCalls.length > 0 || streamContent || isProcessing) && (
Expand Down Expand Up @@ -4235,28 +4367,30 @@ function MessageView({
t,
modeColor,
expandedMessages,
highlightedPrompt,
}: {
entry: ChatEntry;
index: number;
t: Theme;
modeColor: string;
expandedMessages?: Set<number>;
highlightedPrompt?: boolean;
}) {
switch (entry.type) {
case "user":
return (
<box
border={["left"]}
customBorderChars={SPLIT}
borderColor={entry.modeColor || modeColor}
borderColor={highlightedPrompt ? t.accent : entry.modeColor || modeColor}
marginTop={index === 0 ? 0 : 1}
marginBottom={1}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={t.backgroundPanel}
backgroundColor={highlightedPrompt ? t.selectedBg : t.backgroundPanel}
flexShrink={0}
flexDirection="column"
>
Expand Down
54 changes: 54 additions & 0 deletions src/ui/prompt-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
deriveUserPromptIndex,
findNearestPrompt,
getPromptNavigationDirection,
type PromptAnchor,
} from "./prompt-navigation";

describe("deriveUserPromptIndex", () => {
it("returns the positions of user chat entries from the existing entry list", () => {
expect(
deriveUserPromptIndex([{ type: "assistant" }, { type: "user" }, { type: "tool_result" }, { type: "user" }]),
).toEqual([1, 3]);
});

it("also recognizes transcript messages by role", () => {
expect(deriveUserPromptIndex([{ role: "system" }, { role: "user" }, { role: "assistant" }])).toEqual([1]);
});
});

describe("findNearestPrompt", () => {
const anchors: PromptAnchor[] = [
{ messageIndex: 1, offset: 10 },
{ messageIndex: 4, offset: 80 },
{ messageIndex: 7, offset: 160 },
];

it("finds the nearest previous prompt relative to the current scroll", () => {
expect(findNearestPrompt(anchors, 120, "previous")).toEqual(anchors[1]);
});

it("finds the nearest next prompt relative to the current scroll", () => {
expect(findNearestPrompt(anchors, 80, "next")).toEqual(anchors[2]);
});

it("stops at both boundaries without wrapping", () => {
expect(findNearestPrompt(anchors, 0, "previous")).toBeNull();
expect(findNearestPrompt(anchors, 160, "next")).toBeNull();
});
});

describe("getPromptNavigationDirection", () => {
it("maps the unambiguous control-letter events to navigation actions", () => {
expect(getPromptNavigationDirection({ name: "p", ctrl: true, sequence: "\u0010" })).toBe("previous");
expect(getPromptNavigationDirection({ name: "n", ctrl: true, sequence: "\u000e" })).toBe("next");
});

it("does not treat terminal control-bracket bytes or arrow events as navigation", () => {
expect(getPromptNavigationDirection({ name: "escape", sequence: "\u001b" })).toBeNull();
expect(getPromptNavigationDirection({ name: "\u001d", sequence: "\u001d" })).toBeNull();
expect(getPromptNavigationDirection({ name: "up", ctrl: true })).toBeNull();
expect(getPromptNavigationDirection({ name: "down", ctrl: true })).toBeNull();
});
});
Loading