Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
95 changes: 92 additions & 3 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,7 @@ 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 { width, height } = useTerminalDimensions();
const processedInitial = useRef(false);
const contentAccRef = useRef("");
Expand Down Expand Up @@ -1375,6 +1384,8 @@ 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 scrollToBottom = useCallback(() => {
try {
scrollRef.current?.scrollTo(scrollRef.current?.scrollHeight ?? 99999);
Expand All @@ -1383,6 +1394,26 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
}, []);

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(() => {
setStreamContent("");
setStreamReasoning("");
Expand Down Expand Up @@ -1464,7 +1495,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 @@ -2521,6 +2552,52 @@ 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;

scrollBox.scrollTo(target.offset);
flashPrompt(target.messageIndex);
},
Comment thread
cursor[bot] marked this conversation as resolved.
[flashPrompt, 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 (btwState) {
Expand Down Expand Up @@ -3183,6 +3260,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
}

const promptNavigationDirection = getPromptNavigationDirection(key);
if (promptNavigationDirection) {
navigateToPrompt(promptNavigationDirection);
key.preventDefault();
key.stopPropagation();
return;
}

if (key.name === "e" && key.ctrl) {
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
Expand Down Expand Up @@ -3324,6 +3409,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
showSandboxPicker,
pendingPaymentApproval,
processMessage,
navigateToPrompt,
showWalletPicker,
walletSettings,
walletFocusIndex,
Expand Down Expand Up @@ -3439,6 +3525,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 +4322,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();
});
});
66 changes: 66 additions & 0 deletions src/ui/prompt-navigation.ts
Original file line number Diff line number Diff line change
@@ -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;
}