diff --git a/AGENTS.md b/AGENTS.md index 236be4869..5bc77f44c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. \ No newline at end of file +- `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. diff --git a/README.md b/README.md index c103debfd..4f921af79 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 6c2eab442..1815bf57a 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -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 { @@ -661,6 +668,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const [activePlan, setActivePlan] = useState(null); /** Incremented on each successful TUI copy; drives a brief "Copied" banner. */ const [copyFlashId, setCopyFlashId] = useState(0); + const [promptFlashMessageIndex, setPromptFlashMessageIndex] = useState(null); const [expandedMessages, setExpandedMessages] = useState>(() => new Set()); const [activeSubagent, setActiveSubagent] = useState(null); const [pqs, setPqs] = useState(initialPlanQuestionsState()); @@ -669,6 +677,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const apiKeyInputRef = useRef(null); const inputRef = useRef(null); const scrollRef = useRef(null); + const promptFlashTimeoutRef = useRef | null>(null); + const isReviewingPromptsRef = useRef(false); const { width, height } = useTerminalDimensions(); const processedInitial = useRef(false); const contentAccRef = useRef(""); @@ -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(""); @@ -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(() => { @@ -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); + } + 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(() => { @@ -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); @@ -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()); @@ -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; @@ -2156,6 +2220,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) finalizeActiveTurn, scrollToBottom, sessionTitle, + setPromptReviewing, showLiveToolCalls, ], ); @@ -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((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); + }, + [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]); + const handleKey = useCallback( (key: KeyEvent) => { + if (isReviewingPromptsRef.current) { + schedulePromptReviewSync(); + } if (btwState) { if (isEscapeKey(key) || key.name === "return") { dismissBtw(); @@ -3284,6 +3399,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) handlePlanSelect, handleSlashMenuSelect, interruptActiveRun, + schedulePromptReviewSync, isPlanConfirmTab, isProcessing, isSinglePlan, @@ -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; @@ -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; @@ -3429,8 +3555,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) {/* Scrollable messages */} - {/* biome-ignore lint/suspicious/noExplicitAny: OpenTUI type mismatch for stickyStart */} - + {messages.map((msg, i) => ( ))} {liveTurnSourceLabel && (activeToolCalls.length > 0 || streamContent || isProcessing) && ( @@ -4235,12 +4367,14 @@ function MessageView({ t, modeColor, expandedMessages, + highlightedPrompt, }: { entry: ChatEntry; index: number; t: Theme; modeColor: string; expandedMessages?: Set; + highlightedPrompt?: boolean; }) { switch (entry.type) { case "user": @@ -4248,7 +4382,7 @@ function MessageView({ @@ -4256,7 +4390,7 @@ function MessageView({ paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={t.backgroundPanel} + backgroundColor={highlightedPrompt ? t.selectedBg : t.backgroundPanel} flexShrink={0} flexDirection="column" > diff --git a/src/ui/prompt-navigation.test.ts b/src/ui/prompt-navigation.test.ts new file mode 100644 index 000000000..bed927ec6 --- /dev/null +++ b/src/ui/prompt-navigation.test.ts @@ -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(); + }); +}); diff --git a/src/ui/prompt-navigation.ts b/src/ui/prompt-navigation.ts new file mode 100644 index 000000000..3a27b25ae --- /dev/null +++ b/src/ui/prompt-navigation.ts @@ -0,0 +1,66 @@ +export type PromptSourceEntry = { + role?: string; + type?: string; +}; + +export type PromptNavigationDirection = "previous" | "next"; + +export type PromptNavigationKey = { + name?: string; + sequence?: string; + ctrl?: boolean; + shift?: boolean; + option?: boolean; + meta?: boolean; + super?: boolean; +}; + +export interface PromptAnchor { + messageIndex: number; + offset: number; +} + +/** + * Map terminal-safe control-letter events to navigation actions. Control + * letters have unambiguous single-byte encodings and cannot be consumed as + * ordinary arrow movement by a focused textarea or scrollbox. + */ +export function getPromptNavigationDirection(key: PromptNavigationKey): PromptNavigationDirection | null { + if (key.ctrl && !key.shift && !key.option && !key.meta && !key.super) { + if (key.name === "p") return "previous"; + if (key.name === "n") return "next"; + } + return null; +} + +/** + * Chat entries are derived from transcript messages. Keep prompt navigation + * derived from those entries so it cannot drift into a second message index. + */ +export function deriveUserPromptIndex(entries: readonly PromptSourceEntry[]): number[] { + return entries.flatMap((entry, index) => (entry.role === "user" || entry.type === "user" ? [index] : [])); +} + +/** + * Find the closest prompt strictly before or after the current scroll top. + * Strict comparisons make a prompt already at the top count as the current + * prompt, so navigation never wraps at either boundary. + */ +export function findNearestPrompt( + anchors: readonly PromptAnchor[], + currentScrollTop: number, + direction: PromptNavigationDirection, +): PromptAnchor | null { + if (direction === "previous") { + for (let index = anchors.length - 1; index >= 0; index -= 1) { + const anchor = anchors[index]; + if (anchor && anchor.offset < currentScrollTop) return anchor; + } + return null; + } + + for (const anchor of anchors) { + if (anchor.offset > currentScrollTop) return anchor; + } + return null; +}