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 LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

---

This repository includes a port of pi-prompt-suggester
(https://github.com/guwidoe/pi-prompt-suggester),
Copyright (c) 2026 Guido Witt-Dörring, licensed under the MIT License.
See NOTICE.md.
35 changes: 35 additions & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Third-party notices

## pi-prompt-suggester

The next-prompt suggester in `src/suggester/` is a Grok CLI port of
[pi-prompt-suggester](https://github.com/guwidoe/pi-prompt-suggester)
by **Guido Witt-Dörring**.

Copyright (c) 2026 Guido Witt-Dörring

Licensed under the MIT License. A copy of that license is included below.
The original design, two-stage seed + suggestion pipeline, steering
classifications, and several prompt templates were adapted from that
project. This port reimplements the product for Grok CLI / OpenTUI; it
is not a drop-in of the Pi extension.

### MIT License (pi-prompt-suggester)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN ANY ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ Use `/schedule` in the TUI to browse saved schedules. One-time schedules start
immediately in the background; recurring schedules keep running as long as the
daemon is active.

**Next-prompt suggestions** — after each interactive turn, the TUI may ghost a
likely next user prompt and inserts it into the empty editor. It also keeps a
background project-intent seed and learns if you accept or rewrite suggestions.
`Esc` clears an untouched fill. It never auto-sends. `/suggester reseed`
refreshes the seed; `/suggester instruction set ...` adds a preference;
`/suggester ghost` goes back to dim placeholder + `Space`. Headless `--prompt`
mode is unchanged.

This suggester is a port of
[pi-prompt-suggester](https://github.com/guwidoe/pi-prompt-suggester)
by **Guido Witt-Dörring** (MIT). See [`NOTICE.md`](./NOTICE.md).

**List Grok models and pricing hints:**

```bash
Expand Down Expand Up @@ -181,6 +193,7 @@ You keep using a text model for the session, and Grok saves generated media unde
| **Computer use** | Built-in `**computer`** sub-agent for host desktop automation via `**agent-desktop`**. It prefers semantic accessibility snapshots and stable refs, with screenshots saved under `**.grok/computer/**` when requested. |
| **Custom sub-agents** | Define named agents with `**subAgents`** in `**~/.grok/user-settings.json`** and manage them from the TUI with `**/agents**`. |
| **Remote control** | Pair **Telegram** from the TUI (`/remote-control` → Telegram): DM your bot, `**/pair`**, approve the code in-terminal. Keep the CLI running while you ping it from your phone. |
| **Next-prompt suggestions** | After each interactive turn, the TUI inserts a likely next user prompt using conversation context plus a background project-intent seed. `**/suggester**` for status/reseed/instruction. Never auto-sends. |
| **No “mystery meat” UI** | OpenTUI React terminal UI—fast, keyboard-driven, not whatever glitchy thing you’re thinking of. |
| **Skills** | Agent Skills under `**.agents/skills/<name>/SKILL.md`** (project) or `**~/.agents/skills/`** (user). Use `**/skills**` in the TUI to list what’s installed. |
| **MCPs** | Extend with Model Context Protocol servers—configure via `**/mcps`** in the TUI or `**.grok/settings.json`** (`mcpServers`). |
Expand Down Expand Up @@ -221,6 +234,18 @@ grok -k your_key_here
{ "apiKey": "your_key_here" }
```

Optional `**suggester**` — next-prompt ghosts after each interactive turn (`enabled` defaults to `true`):

```json
{
"suggester": {
"enabled": true,
"model": "grok-3-mini",
"maxSuggestionChars": 200
}
}
```

Optional `**subAgents**` — custom foreground sub-agents. Each entry needs `**name**`, `**model**`, and `**instruction**`:

```json
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
}
},
"bin": {
"grok": "dist/index.js"
"grok": "dist/index.js",
"grok-dev": "dist/index.js"
},
"scripts": {
"dev": "bun run src/index.ts",
Expand Down
13 changes: 13 additions & 0 deletions src/suggester/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Next-prompt suggester

Grok CLI port of [pi-prompt-suggester](https://github.com/guwidoe/pi-prompt-suggester)
by **Guido Witt-Dörring** (MIT, 2026).

The original is a Pi coding-agent extension. This module is a first-class
rewrite for Grok CLI: same product (intent seed + per-turn next-prompt guess
+ steering), OpenTUI wiring instead of Pi's `ExtensionAPI`.

Adapted files carry an inline copyright header. See [`NOTICE.md`](../../NOTICE.md)
at the repo root for the full license text.

Upstream: https://github.com/guwidoe/pi-prompt-suggester
28 changes: 28 additions & 0 deletions src/suggester/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseSuggesterCommand } from "./command.js";

describe("parseSuggesterCommand", () => {
it("parses on, off, status, and help", () => {
expect(parseSuggesterCommand("/suggester")).toEqual({ action: "status", rest: "" });
expect(parseSuggesterCommand("/suggester status")).toEqual({ action: "status", rest: "" });
expect(parseSuggesterCommand("/suggester on")).toEqual({ action: "on", rest: "" });
expect(parseSuggesterCommand("/SUGGESTER off")).toEqual({ action: "off", rest: "" });
expect(parseSuggesterCommand("/suggester auto")).toEqual({ action: "auto", rest: "" });
expect(parseSuggesterCommand("/suggester ghost")).toEqual({ action: "ghost", rest: "" });
expect(parseSuggesterCommand("/suggester reseed")).toEqual({ action: "reseed", rest: "" });
expect(parseSuggesterCommand("/suggester instruction set keep it short")).toEqual({
action: "instruction",
rest: "set keep it short",
});
expect(parseSuggesterCommand("/suggester model seeder grok-3-mini")).toEqual({
action: "model",
rest: "seeder grok-3-mini",
});
expect(parseSuggesterCommand("/suggester mystery")).toEqual({ action: "help", rest: "" });
});

it("ignores unrelated commands", () => {
expect(parseSuggesterCommand("/btw hello")).toBeNull();
expect(parseSuggesterCommand("suggester on")).toBeNull();
});
});
51 changes: 51 additions & 0 deletions src/suggester/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type SuggesterCommandAction =
| "status"
| "on"
| "off"
| "auto"
| "ghost"
| "reseed"
| "instruction"
| "model"
| "help";

export interface ParsedSuggesterCommand {
action: SuggesterCommandAction;
rest: string;
}

const SIMPLE_ACTIONS = new Set<SuggesterCommandAction>(["status", "on", "off", "auto", "ghost", "reseed"]);

export function parseSuggesterCommand(cmd: string): ParsedSuggesterCommand | null {
const trimmed = cmd.trim();
if (!trimmed.startsWith("/")) return null;
const body = trimmed.slice(1).trim();
const [first, ...tail] = body.split(/\s+/).filter(Boolean);
if ((first ?? "").toLowerCase() !== "suggester") return null;

const sub = (tail[0] ?? "status").toLowerCase();
const rest = tail.slice(1).join(" ");
if (SIMPLE_ACTIONS.has(sub as SuggesterCommandAction)) {
return { action: sub as SuggesterCommandAction, rest };
}
if (sub === "instruction" || sub === "model") {
return { action: sub, rest };
}
return { action: "help", rest: "" };
}

export function suggesterUsageText(): string {
return [
"Usage:",
" /suggester Show status",
" /suggester on|off Enable or disable",
" /suggester auto|ghost Auto-insert or dim ghost",
" /suggester reseed Refresh project-intent seed",
" /suggester instruction Show custom instruction",
" /suggester instruction set <text>",
" /suggester instruction clear",
" /suggester model Show suggester/seeder models",
" /suggester model set <id>",
" /suggester model seeder <id>",
].join("\n");
}
27 changes: 27 additions & 0 deletions src/suggester/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SUGGESTER_SETTINGS, resolveSuggesterSettings } from "./config.js";

describe("resolveSuggesterSettings", () => {
it("defaults to enabled with the cheap model", () => {
expect(resolveSuggesterSettings()).toEqual(DEFAULT_SUGGESTER_SETTINGS);
expect(resolveSuggesterSettings({})).toMatchObject({
enabled: true,
model: "grok-3-mini",
seederModel: "grok-4.20-non-reasoning",
ghostAcceptKeys: ["space", "right"],
fastPathContinueOnError: true,
autoAccept: true,
});
});

it("treats enabled: false as off and clamps max chars", () => {
expect(resolveSuggesterSettings({ enabled: false }).enabled).toBe(false);
expect(resolveSuggesterSettings({ maxSuggestionChars: 5 }).maxSuggestionChars).toBe(20);
expect(resolveSuggesterSettings({ maxSuggestionChars: 9_000 }).maxSuggestionChars).toBe(500);
});

it("drops unknown accept keys and falls back when empty", () => {
expect(resolveSuggesterSettings({ ghostAcceptKeys: ["space"] }).ghostAcceptKeys).toEqual(["space"]);
expect(resolveSuggesterSettings({ ghostAcceptKeys: [] }).ghostAcceptKeys).toEqual(["space", "right"]);
});
});
88 changes: 88 additions & 0 deletions src/suggester/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { normalizeModelId } from "../grok/models.js";
import { loadUserSettings, saveUserSettings } from "../utils/settings.js";
import type { GhostAcceptKey, ResolvedSuggesterSettings, SuggesterSettings } from "./types.js";

export const DEFAULT_SUGGESTER_MODEL = "grok-3-mini";
export const DEFAULT_SEEDER_MODEL = "grok-4.20-non-reasoning";
export const NO_SUGGESTION_TOKEN = "[no suggestion]";
export const DEFAULT_RESEED_TURN_INTERVAL = 10;
export const DEFAULT_MAX_SUGGESTION_CHARS = 200;
export const DEFAULT_MAX_ASSISTANT_TURN_CHARS = 8_000;
export const DEFAULT_MAX_RECENT_USER_PROMPTS = 20;
export const DEFAULT_MAX_RECENT_USER_PROMPT_CHARS = 500;
export const DEFAULT_MAX_TOOL_SIGNALS = 8;
export const DEFAULT_MAX_TOOL_SIGNAL_CHARS = 240;
export const DEFAULT_MAX_TOUCHED_FILES = 8;
export const DEFAULT_MAX_UNRESOLVED_QUESTIONS = 6;

export const DEFAULT_SUGGESTER_SETTINGS: ResolvedSuggesterSettings = {
enabled: true,
model: DEFAULT_SUGGESTER_MODEL,
seederModel: DEFAULT_SEEDER_MODEL,
maxSuggestionChars: DEFAULT_MAX_SUGGESTION_CHARS,
ghostAcceptKeys: ["space", "right"],
fastPathContinueOnError: true,
autoAccept: true,
customInstruction: "",
reseedEnabled: true,
reseedTurnInterval: DEFAULT_RESEED_TURN_INTERVAL,
};

export function normalizeGhostAcceptKeys(value: unknown): GhostAcceptKey[] {
if (!Array.isArray(value)) return [...DEFAULT_SUGGESTER_SETTINGS.ghostAcceptKeys];
const keys = value.filter((item): item is GhostAcceptKey => item === "space" || item === "right");
return keys.length > 0 ? [...new Set(keys)] : [...DEFAULT_SUGGESTER_SETTINGS.ghostAcceptKeys];
}

export function resolveSuggesterSettings(raw?: SuggesterSettings): ResolvedSuggesterSettings {
const maxChars =
typeof raw?.maxSuggestionChars === "number" && Number.isFinite(raw.maxSuggestionChars)
? Math.max(20, Math.min(500, Math.floor(raw.maxSuggestionChars)))
: DEFAULT_SUGGESTER_SETTINGS.maxSuggestionChars;

return {
enabled: raw?.enabled !== false,
model: raw?.model ? normalizeModelId(raw.model) : DEFAULT_SUGGESTER_SETTINGS.model,
seederModel: raw?.seederModel ? normalizeModelId(raw.seederModel) : DEFAULT_SUGGESTER_SETTINGS.seederModel,
maxSuggestionChars: maxChars,
ghostAcceptKeys: normalizeGhostAcceptKeys(raw?.ghostAcceptKeys),
fastPathContinueOnError: raw?.fastPathContinueOnError !== false,
autoAccept: raw?.autoAccept !== false,
customInstruction: typeof raw?.customInstruction === "string" ? raw.customInstruction : "",
reseedEnabled: raw?.reseedEnabled !== false,
reseedTurnInterval:
typeof raw?.reseedTurnInterval === "number" && Number.isFinite(raw.reseedTurnInterval)
? Math.max(1, Math.min(100, Math.floor(raw.reseedTurnInterval)))
: DEFAULT_SUGGESTER_SETTINGS.reseedTurnInterval,
};
}

export function loadSuggesterSettings(): ResolvedSuggesterSettings {
return resolveSuggesterSettings(loadUserSettings().suggester);
}

export function saveSuggesterSettings(partial: SuggesterSettings): ResolvedSuggesterSettings {
const current = loadSuggesterSettings();
const next = resolveSuggesterSettings({
...current,
...partial,
...(partial.ghostAcceptKeys !== undefined
? { ghostAcceptKeys: normalizeGhostAcceptKeys(partial.ghostAcceptKeys) }
: {}),
});
saveUserSettings({
suggester: {
enabled: next.enabled,
model: next.model,
seederModel: next.seederModel,
maxSuggestionChars: next.maxSuggestionChars,
ghostAcceptKeys: next.ghostAcceptKeys,
fastPathContinueOnError: next.fastPathContinueOnError,
autoAccept: next.autoAccept,
customInstruction: next.customInstruction,
reseedEnabled: next.reseedEnabled,
reseedTurnInterval: next.reseedTurnInterval,
},
});
return next;
}
65 changes: 65 additions & 0 deletions src/suggester/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { ChatEntry } from "../types/index.js";
import { buildSuggestionContext, sliceLastTurn } from "./context.js";

function entry(partial: Partial<ChatEntry> & Pick<ChatEntry, "type" | "content">): ChatEntry {
return { timestamp: new Date("2026-08-16T12:00:00.000Z"), ...partial };
}

describe("sliceLastTurn", () => {
it("returns entries after the last user message", () => {
const entries = [
entry({ type: "user", content: "first" }),
entry({ type: "assistant", content: "ok" }),
entry({ type: "user", content: "second" }),
entry({ type: "tool_result", content: "wrote file", toolCall: tool("write_file", { path: "src/a.ts" }) }),
entry({ type: "assistant", content: "done" }),
];
expect(sliceLastTurn(entries).map((item) => item.content)).toEqual(["second", "wrote file", "done"]);
});
});

describe("buildSuggestionContext", () => {
it("collects recent user prompts, tool signals, touched files, and questions", () => {
const entries = [
entry({ type: "user", content: "add tests" }),
entry({ type: "assistant", content: "I can add them." }),
entry({ type: "user", content: "do it for context.ts" }),
entry({
type: "tool_result",
content: "ok",
toolCall: tool("write_file", { path: "src/suggester/context.ts" }),
toolResult: {
success: true,
output: "wrote context.ts",
diff: { filePath: "src/suggester/context.ts", additions: 1, removals: 0, patch: "", isNew: false },
},
}),
entry({ type: "assistant", content: "Added tests.\nWant me to run them?" }),
];

const context = buildSuggestionContext(entries, "success");
expect(context.turnStatus).toBe("success");
expect(context.recentUserPrompts).toEqual(["add tests", "do it for context.ts"]);
expect(context.latestAssistantTurn).toContain("Want me to run them?");
expect(context.toolSignals[0]).toContain("write_file: ok");
expect(context.touchedFiles).toContain("src/suggester/context.ts");
expect(context.unresolvedQuestions.some((item) => item.includes("run them"))).toBe(true);
});

it("passes abort notes through", () => {
const context = buildSuggestionContext([entry({ type: "user", content: "go" })], "aborted", {
abortNote: "User interrupted the previous turn.",
});
expect(context.abortContextNote).toBe("User interrupted the previous turn.");
expect(context.turnStatus).toBe("aborted");
});
});

function tool(name: string, args: Record<string, unknown>) {
return {
id: "call-1",
type: "function" as const,
function: { name, arguments: JSON.stringify(args) },
};
}
Loading