Skip to content

Commit ce7dbbc

Browse files
committed
feat(ui): inline ghost-text suggestion of the user's likely next message
After each idle moment the agent has, fire a one-shot side call on the main model that re-uses the parent's system prompt + conversation prefix and asks for a 2-12 word prediction of what the user would type next. The shared prefix lets native prompt-cache layers (Anthropic, OpenAI) hit on the parent's cached prefix so cost is roughly the suggestion's output tokens; Codebase Auto is zero marginal cost regardless. Ghost text renders dim after the prompt cursor with a small 'tab' affordance. Tab on an empty buffer accepts (fills with the suggestion); any other keystroke dismisses it. Disable via CODEBASE_NO_SUGGESTIONS=1. Prompt text and filter list are original — the technique itself is generic but we don't ship anyone else's wording.
1 parent fdd8e01 commit ce7dbbc

3 files changed

Lines changed: 245 additions & 2 deletions

File tree

src/agent/prompt-suggestion.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { completeSimple } from "@earendil-works/pi-ai";
2+
import type { AgentBundle } from "./agent.js";
3+
4+
/**
5+
* Prompt-suggestion ghost-text generation. After the agent goes idle,
6+
* we issue a one-shot side call on the user's main model that reuses
7+
* the parent's system prompt + conversation prefix and asks the model
8+
* to predict the user's next input as a short string. The matching
9+
* prefix lets the upstream's prompt cache reuse the parent's work, so
10+
* the marginal cost is approximately the suggestion's own output
11+
* tokens.
12+
*
13+
* Cost notes by provider:
14+
* - Codebase Auto: zero marginal cost (in-house inference).
15+
* - Anthropic / OpenAI: small (native prompt caching hits the prefix).
16+
* - Other BYOK upstreams (Groq, Mistral, …): a small uncached call
17+
* per suggestion. Disable via CODEBASE_NO_SUGGESTIONS=1.
18+
*/
19+
20+
/**
21+
* Instructions appended as a final user message. The *technique* —
22+
* meta-prompting an assistant to predict the next user input — is a
23+
* common pattern across coding tools, but the wording below is
24+
* original to this project (Claude Code uses a similar idea with
25+
* different words; we're not copying that text).
26+
*/
27+
const SUGGESTION_PROMPT = `Switching to autocomplete mode for one reply.
28+
29+
Reread the conversation so far and forecast the user's next message — the words they themselves would type into the input box, not advice about what they should do.
30+
31+
Calibration: if the user would read your reply and think "yeah, I was about to send that," you nailed it. If they'd read it and think "the assistant is talking to me," you missed.
32+
33+
Guideline patterns:
34+
- After a job that finished cleanly, the next ask is usually the obvious follow-through: run the tests, push the commit, open the PR, ship it.
35+
- When you (the assistant) listed options, prefer the one the user clearly wants given the thread above.
36+
- When you asked a yes/no, pick the answer their tone implies.
37+
- When the last assistant turn was a stack trace, a clarifying question, or anything where the user needs a beat to react: produce nothing.
38+
39+
Things that disqualify a suggestion:
40+
- Anything in your own voice ("I'll handle it", "Let me check", "Here's a plan").
41+
- Praise or filler the user would never type to drive forward ("nice", "looks great", "thanks").
42+
- Open-ended questions thrown back at you ("what do you think?").
43+
- A pivot to a new topic the user didn't ask about.
44+
- More than one sentence; more than about a dozen words.
45+
46+
Output: just the predicted user message, no quotes, no preamble, no trailing punctuation flourish. If nothing fits, return an empty reply.`;
47+
48+
/** Don't suggest before this many assistant turns — needs context to predict from. */
49+
const MIN_ASSISTANT_TURNS = 2;
50+
51+
/** Cap output so a runaway model can't burn tokens. 60 ≈ 12 words × 5 token/word, with slack. */
52+
const MAX_SUGGESTION_TOKENS = 60;
53+
54+
/**
55+
* Try to produce a suggestion for the current conversation. Returns null
56+
* when we shouldn't suggest (early turn, last response was an error,
57+
* abort fired, model returned empty or filtered text).
58+
*/
59+
export async function generateSuggestion(
60+
bundle: AgentBundle,
61+
options: { signal?: AbortSignal } = {},
62+
): Promise<string | null> {
63+
const agentState = bundle.agent.state;
64+
const messages = agentState.messages;
65+
66+
// Need at least a couple assistant turns to predict from — anything
67+
// less and the suggestion is guessing into the void.
68+
const assistantTurnCount = messages.reduce((n, m) => n + (m.role === "assistant" ? 1 : 0), 0);
69+
if (assistantTurnCount < MIN_ASSISTANT_TURNS) return null;
70+
71+
// If the latest assistant message errored, the user should read/react,
72+
// not be nudged to type more.
73+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
74+
if (lastAssistant && "errorMessage" in lastAssistant && lastAssistant.errorMessage) return null;
75+
76+
const suggestionMessages = [
77+
...messages,
78+
{
79+
role: "user" as const,
80+
content: SUGGESTION_PROMPT,
81+
timestamp: Date.now(),
82+
},
83+
];
84+
85+
let assistantMessage: Awaited<ReturnType<typeof completeSimple>>;
86+
try {
87+
assistantMessage = await completeSimple(
88+
agentState.model,
89+
{
90+
systemPrompt: agentState.systemPrompt,
91+
messages: suggestionMessages,
92+
},
93+
{
94+
signal: options.signal,
95+
maxTokens: MAX_SUGGESTION_TOKENS,
96+
temperature: 0.7,
97+
// `cacheRetention: "short"` is pi-ai's default — keeps the
98+
// suggestion fork from extending the parent's cache TTL,
99+
// which the agent loop manages on its own.
100+
},
101+
);
102+
} catch {
103+
return null;
104+
}
105+
106+
const text = extractText(assistantMessage).trim();
107+
if (!text) return null;
108+
if (shouldFilterSuggestion(text)) return null;
109+
110+
return text;
111+
}
112+
113+
function extractText(message: Awaited<ReturnType<typeof completeSimple>>): string {
114+
if (typeof message.content === "string") return message.content;
115+
if (!Array.isArray(message.content)) return "";
116+
const parts: string[] = [];
117+
for (const block of message.content) {
118+
if (block.type === "text" && typeof block.text === "string") {
119+
parts.push(block.text);
120+
}
121+
}
122+
return parts.join("");
123+
}
124+
125+
/**
126+
* Filter out suggestions that don't look like real user input. The list
127+
* is a pragmatic subset of Claude Code's filters — anything the model
128+
* tends to emit when it doesn't know what to suggest but isn't quite
129+
* willing to say nothing.
130+
*/
131+
function shouldFilterSuggestion(text: string): boolean {
132+
const lower = text.toLowerCase();
133+
if (lower === "done" || lower === "done.") return true;
134+
if (lower === "nothing found" || lower === "nothing found.") return true;
135+
if (lower.startsWith("nothing to suggest") || lower.startsWith("no suggestion")) return true;
136+
if (/\bstay(s|ing)? silent\b|\bsilence is\b/.test(lower)) return true;
137+
if (/^\W*silence\W*$/.test(lower)) return true;
138+
// Assistant-voice slippage — model addresses the user as if it were
139+
// replying in-character instead of predicting their input.
140+
if (/^(let me|i('|\s)ll|here('|\s)s|i can|i('|\s)d)\b/i.test(text)) return true;
141+
// Evaluative one-liners — these aren't actions, they're chitchat the
142+
// user wouldn't typically type to drive forward.
143+
if (/^(looks good|thanks|nice|great|ok|okay|yes)\.?$/i.test(text.trim())) return true;
144+
// Too long — keep ghost-text legible (12 words is the cap CC uses).
145+
if (text.split(/\s+/).length > 12) return true;
146+
// Multi-sentence — pick a single utterance.
147+
if (/[.!?]\s+\S/.test(text)) return true;
148+
return false;
149+
}

src/ui/App.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useReducer, useRef, useState } from "react";
55
import { type AgentBundle, createAgent } from "../agent/agent.js";
66
import { ConfigError } from "../agent/config.js";
77
import { initialState, reducer } from "../agent/events.js";
8+
import { generateSuggestion } from "../agent/prompt-suggestion.js";
89
import { routeUserInput } from "../agent/router.js";
910
import { BUILTIN_COMMANDS } from "../commands/builtins.js";
1011
import { CommandRegistry } from "../commands/registry.js";
@@ -211,6 +212,46 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
211212

212213
const busy = state.status === "thinking" || state.status === "streaming" || state.status === "tool";
213214

215+
// Inline prompt-suggestion ghost text. Schedules a single forecast
216+
// call when the agent goes idle; cancels the prior call on every new
217+
// state change so we never race two suggestions or show a stale one
218+
// after the user starts a new turn. 500ms debounce lets idle settle
219+
// (e.g. agent finishes, a quick status emit follows, we don't want to
220+
// fire twice). Disabled via env so users on metered BYOK providers
221+
// can opt out.
222+
const [suggestion, setSuggestion] = useState<string | null>(null);
223+
const suggestionAbortRef = useRef<AbortController | null>(null);
224+
useEffect(() => {
225+
// Always clear any active suggestion on state change — it was
226+
// computed for the previous turn and the user has moved on.
227+
setSuggestion(null);
228+
suggestionAbortRef.current?.abort();
229+
suggestionAbortRef.current = null;
230+
231+
if (process.env.CODEBASE_NO_SUGGESTIONS === "1") return;
232+
if (state.status !== "idle") return;
233+
if (state.messages.length < 2) return;
234+
235+
const ac = new AbortController();
236+
suggestionAbortRef.current = ac;
237+
const timer = setTimeout(async () => {
238+
if (ac.signal.aborted) return;
239+
try {
240+
const text = await generateSuggestion(bundle, { signal: ac.signal });
241+
if (ac.signal.aborted) return;
242+
if (text) setSuggestion(text);
243+
} catch {
244+
// Suggestion failures are silent — they're a nicety, not load-bearing.
245+
}
246+
}, 500);
247+
248+
return () => {
249+
clearTimeout(timer);
250+
ac.abort();
251+
if (suggestionAbortRef.current === ac) suggestionAbortRef.current = null;
252+
};
253+
}, [bundle, state.status, state.messages.length]);
254+
214255
const handleSubmit = async (text: string) => {
215256
// `!cmd` runs a shell command directly — the CC convention for
216257
// "I just want to check something real quick" without involving
@@ -426,6 +467,8 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
426467
commands={commandSuggestions}
427468
history={inputHistory}
428469
cwd={bundle.toolContext.cwd}
470+
suggestion={suggestion}
471+
onSuggestionDismiss={() => setSuggestion(null)}
429472
/>
430473
)}
431474
</Box>

src/ui/Input.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ interface InputProps {
3737
history?: readonly string[];
3838
/** Working directory used to resolve @-token Tab completion. */
3939
cwd?: string;
40+
/**
41+
* Inline ghost-text suggestion to render after an empty input prompt.
42+
* Tab accepts (fills the buffer); typing anything else dismisses it.
43+
* Null when no suggestion is active.
44+
*/
45+
suggestion?: string | null;
46+
/** Called when the suggestion should clear (user typed or accepted it). */
47+
onSuggestionDismiss?: () => void;
4048
}
4149

4250
const MAX_SUGGESTIONS = 6;
@@ -84,7 +92,16 @@ function pickPlaceholder(hasHistory: boolean): string {
8492
* Ctrl-Z undo
8593
* Ctrl-C busy → cancel turn (stay in app); double-tap → exit
8694
*/
87-
export function Input({ disabled, onSubmit, onAbort, commands, history, cwd }: InputProps) {
95+
export function Input({
96+
disabled,
97+
onSubmit,
98+
onAbort,
99+
commands,
100+
history,
101+
cwd,
102+
suggestion,
103+
onSuggestionDismiss,
104+
}: InputProps) {
88105
const [state, setState] = useState(initialInputState());
89106
const [suggestionIdx, setSuggestionIdx] = useState(0);
90107
/**
@@ -132,6 +149,32 @@ export function Input({ disabled, onSubmit, onAbort, commands, history, cwd }: I
132149

133150
if (disabled) return;
134151

152+
// Ghost-text suggestion accept. Only fires when there's no slash
153+
// or @-path completion in flight AND the buffer is empty (the
154+
// suggestion is contextual to the conversation, not to whatever
155+
// the user is mid-typing). Tab fills the buffer and clears the
156+
// suggestion. Any other keystroke dismisses the suggestion so it
157+
// doesn't keep flashing after the user has started a fresh idea.
158+
if (suggestion && state.buffer.length === 0) {
159+
if (key.tab && !autocompleteActive) {
160+
setState({ ...initialInputState(), buffer: suggestion, cursor: suggestion.length });
161+
onSuggestionDismiss?.();
162+
return;
163+
}
164+
// Anything user-driven that isn't bare cursor navigation should
165+
// kill the ghost — they've moved on.
166+
const isPassThrough =
167+
key.upArrow ||
168+
key.downArrow ||
169+
key.leftArrow ||
170+
key.rightArrow ||
171+
key.escape ||
172+
(key.ctrl && (input === "c" || input === "d"));
173+
if (!isPassThrough) {
174+
onSuggestionDismiss?.();
175+
}
176+
}
177+
135178
// Autocomplete navigation runs BEFORE generic input handling so Tab
136179
// doesn't insert a literal tab and arrow keys don't fight cursor
137180
// movement when we have a suggestion list to navigate.
@@ -333,7 +376,15 @@ export function Input({ disabled, onSubmit, onAbort, commands, history, cwd }: I
333376
) : state.buffer.length === 0 ? (
334377
<>
335378
<Text color="cyan"></Text>
336-
<Text dimColor>{placeholderRef.current}</Text>
379+
{suggestion ? (
380+
<>
381+
<Text dimColor>{suggestion}</Text>
382+
<Text dimColor>{" "}</Text>
383+
<Text dimColor>↹ tab</Text>
384+
</>
385+
) : (
386+
<Text dimColor>{placeholderRef.current}</Text>
387+
)}
337388
</>
338389
) : (
339390
<RenderedBuffer buffer={state.buffer} cursor={state.cursor} />

0 commit comments

Comments
 (0)