Merge pull request #2281 from jeremymcs/worktree-local-commands-stay-local

feat: contextual tips
This commit is contained in:
Jeremy McSpadden 2026-04-10 07:38:18 -05:00 committed by GitHub
commit da352847e2
6 changed files with 556 additions and 0 deletions

View file

@ -0,0 +1,259 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { ContextualTips } from "./contextual-tips.js";
const baseCtx = {
input: "hello world",
isStreaming: false,
thinkingLevel: "off" as string,
contextPercent: undefined as number | undefined,
};
describe("ContextualTips", () => {
describe("shell-command-prefix tip", () => {
it("fires for bare shell commands", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "ls -la" });
assert.ok(result);
assert.ok(result.includes("looks like a shell command"));
assert.ok(result.includes("!"));
});
it("fires for various known commands", () => {
for (const cmd of ["pwd", "cd src", "cat file.txt", "grep foo bar", "git status", "npm install", "docker ps"]) {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: cmd });
assert.ok(result, `Expected tip for "${cmd}"`);
assert.ok(result.includes("looks like a shell command"));
}
});
it("does not fire for commands already prefixed with !", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "!ls -la" });
assert.equal(result, null);
});
it("does not fire for commands prefixed with !!", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "!!ls -la" });
assert.equal(result, null);
});
it("does not fire for slash commands", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "/clear" });
assert.equal(result, null);
});
it("does not fire for unknown commands", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "please help me fix this bug" });
assert.equal(result, null);
});
it("does not fire for very long inputs", () => {
const tips = new ContextualTips();
const longInput = "ls " + "a".repeat(200);
const result = tips.evaluate({ ...baseCtx, input: longInput });
assert.equal(result, null);
});
it("respects maxShows (2)", () => {
const tips = new ContextualTips();
tips.evaluate({ ...baseCtx, input: "ls" });
tips.evaluate({ ...baseCtx, input: "pwd" });
const third = tips.evaluate({ ...baseCtx, input: "cat foo" });
assert.equal(third, null);
});
});
describe("large-paste tip", () => {
it("fires for large inputs", () => {
const tips = new ContextualTips();
const largeInput = "a".repeat(2500);
const result = tips.evaluate({ ...baseCtx, input: largeInput });
assert.ok(result);
assert.ok(result.includes("Large inputs"));
});
it("does not fire for normal-length inputs", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "fix the login bug" });
assert.equal(result, null);
});
it("does not fire for large bash commands", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "!" + "a".repeat(2500) });
assert.equal(result, null);
});
it("respects maxShows (2)", () => {
const tips = new ContextualTips();
const large = "x".repeat(3000);
tips.evaluate({ ...baseCtx, input: large });
tips.evaluate({ ...baseCtx, input: large });
const third = tips.evaluate({ ...baseCtx, input: large });
assert.equal(third, null);
});
});
describe("thinking-level-high tip", () => {
it("fires for short inputs with high thinking", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "what is 2+2?", thinkingLevel: "high" });
assert.ok(result);
assert.ok(result.includes("Thinking is set to high"));
});
it("fires for xhigh thinking", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "what time is it?", thinkingLevel: "xhigh" });
assert.ok(result);
assert.ok(result.includes("Thinking is set to xhigh"));
});
it("does not fire for low/medium thinking", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "what is 2+2?", thinkingLevel: "medium" });
assert.equal(result, null);
});
it("does not fire for long inputs", () => {
const tips = new ContextualTips();
const longInput = "Please help me refactor this entire authentication module to use JWT tokens instead of session cookies. " +
"I need to update the middleware, the login handler, and the user model.";
const result = tips.evaluate({ ...baseCtx, input: longInput, thinkingLevel: "high" });
assert.equal(result, null);
});
it("does not fire for slash commands", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "/model", thinkingLevel: "high" });
assert.equal(result, null);
});
it("respects maxShows (1)", () => {
const tips = new ContextualTips();
tips.evaluate({ ...baseCtx, input: "hi", thinkingLevel: "high" });
const second = tips.evaluate({ ...baseCtx, input: "hello", thinkingLevel: "high" });
assert.equal(second, null);
});
});
describe("double-bang-reminder tip", () => {
it("fires after 3+ included bash commands", () => {
const tips = new ContextualTips();
tips.recordBashIncluded();
tips.recordBashIncluded();
tips.recordBashIncluded();
const result = tips.evaluate({ ...baseCtx, input: "!ls" });
assert.ok(result);
assert.ok(result.includes("!!"));
});
it("does not fire with fewer than 3 included commands", () => {
const tips = new ContextualTips();
tips.recordBashIncluded();
tips.recordBashIncluded();
const result = tips.evaluate({ ...baseCtx, input: "!ls" });
assert.equal(result, null);
});
it("does not fire for !! commands", () => {
const tips = new ContextualTips();
tips.recordBashIncluded();
tips.recordBashIncluded();
tips.recordBashIncluded();
const result = tips.evaluate({ ...baseCtx, input: "!!ls" });
assert.equal(result, null);
});
it("respects maxShows (2)", () => {
const tips = new ContextualTips();
for (let i = 0; i < 5; i++) tips.recordBashIncluded();
tips.evaluate({ ...baseCtx, input: "!ls" });
tips.evaluate({ ...baseCtx, input: "!pwd" });
const third = tips.evaluate({ ...baseCtx, input: "!cat foo" });
assert.equal(third, null);
});
});
describe("compaction-nudge tip", () => {
it("fires when context is >= 70%", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "fix the bug", contextPercent: 75 });
assert.ok(result);
assert.ok(result.includes("/compact"));
});
it("does not fire when context is < 70%", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "fix the bug", contextPercent: 50 });
assert.equal(result, null);
});
it("does not fire when contextPercent is undefined", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "fix the bug", contextPercent: undefined });
assert.equal(result, null);
});
it("does not fire for slash commands", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "/model", contextPercent: 90 });
assert.equal(result, null);
});
it("respects maxShows (1)", () => {
const tips = new ContextualTips();
tips.evaluate({ ...baseCtx, input: "hello", contextPercent: 80 });
const second = tips.evaluate({ ...baseCtx, input: "world", contextPercent: 85 });
assert.equal(second, null);
});
});
describe("reset", () => {
it("resets all show counters", () => {
const tips = new ContextualTips();
// Exhaust shell-command-prefix tip
tips.evaluate({ ...baseCtx, input: "ls" });
tips.evaluate({ ...baseCtx, input: "pwd" });
assert.equal(tips.evaluate({ ...baseCtx, input: "cat foo" }), null);
tips.reset();
// Should fire again after reset
const result = tips.evaluate({ ...baseCtx, input: "ls" });
assert.ok(result);
assert.ok(result.includes("looks like a shell command"));
});
it("resets bash included count", () => {
const tips = new ContextualTips();
for (let i = 0; i < 5; i++) tips.recordBashIncluded();
assert.equal(tips.bashIncludedCount, 5);
tips.reset();
assert.equal(tips.bashIncludedCount, 0);
});
});
describe("priority — first match wins", () => {
it("shell-command-prefix takes priority over compaction nudge", () => {
const tips = new ContextualTips();
const result = tips.evaluate({ ...baseCtx, input: "ls", contextPercent: 80 });
assert.ok(result);
assert.ok(result.includes("looks like a shell command"));
});
it("large-paste takes priority over compaction nudge", () => {
const tips = new ContextualTips();
const largeInput = "x".repeat(3000);
const result = tips.evaluate({ ...baseCtx, input: largeInput, contextPercent: 80 });
assert.ok(result);
assert.ok(result.includes("Large inputs"));
});
});
});

View file

@ -0,0 +1,232 @@
/**
* Contextual tips system shows non-intrusive, session-scoped hints
* when user behavior suggests they'd benefit from knowing a feature.
*
* Each tip fires at most `maxShows` times per session. Tips are
* evaluated in order; the first match wins per input event.
*/
// ─── Tip definitions ─────────────────────────────────────────────────────────
export interface TipContext {
/** The raw input text the user submitted */
input: string;
/** Whether the agent is currently streaming */
isStreaming: boolean;
/** Current thinking level (e.g. "off", "low", "high", "xhigh") */
thinkingLevel?: string;
/** Number of `!` (included) bash commands run this session */
bashIncludedCount: number;
/** Approximate context usage percentage (0100), if known */
contextPercent?: number;
}
export interface Tip {
id: string;
/** Maximum times this tip is shown per session */
maxShows: number;
/** Returns the tip message if the tip should fire, or null to skip */
evaluate: (ctx: TipContext) => string | null;
}
// Shell commands that obviously run locally and don't need the LLM.
// Intentionally conservative — these are unambiguous filesystem/info commands.
const LOCAL_SHELL_COMMANDS = new Set([
"ls",
"ll",
"la",
"pwd",
"cd",
"dir",
"cat",
"head",
"tail",
"wc",
"file",
"which",
"whoami",
"echo",
"date",
"tree",
"find",
"grep",
"rg",
"clear",
"env",
"df",
"du",
"uname",
"hostname",
"mkdir",
"rm",
"cp",
"mv",
"touch",
"chmod",
"less",
"more",
"sort",
"uniq",
"sed",
"awk",
"curl",
"wget",
"tar",
"zip",
"unzip",
"git",
"docker",
"npm",
"npx",
"yarn",
"pnpm",
"node",
"python",
"python3",
"pip",
"pip3",
"make",
"cargo",
"go",
"ruby",
"brew",
]);
/**
* Extract the first token from input, ignoring leading whitespace.
* Returns lowercase for case-insensitive matching.
*/
function firstToken(input: string): string {
const trimmed = input.trimStart();
const spaceIdx = trimmed.search(/\s/);
const token = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
return token.toLowerCase();
}
/**
* Check if input looks like a bare shell command (no !, //, or slash prefix).
*/
function looksLikeShellCommand(input: string): boolean {
const trimmed = input.trimStart();
// Already prefixed — user knows what they're doing
if (trimmed.startsWith("!") || trimmed.startsWith("/")) return false;
// Multi-line or very long inputs are probably prompts
if (trimmed.includes("\n") || trimmed.length > 120) return false;
return LOCAL_SHELL_COMMANDS.has(firstToken(trimmed));
}
const TIPS: Tip[] = [
// 1. Shell command reminder
{
id: "shell-command-prefix",
maxShows: 2,
evaluate(ctx) {
if (!looksLikeShellCommand(ctx.input)) return null;
const cmd = firstToken(ctx.input);
return `Tip: "${cmd}" looks like a shell command. Prefix with ! to run locally, or !! to run without using tokens.`;
},
},
// 2. Large paste warning
{
id: "large-paste",
maxShows: 2,
evaluate(ctx) {
if (ctx.input.length < 2000) return null;
// Slash commands and bash prefixes are intentional
if (ctx.input.trimStart().startsWith("/") || ctx.input.trimStart().startsWith("!")) return null;
return "Tip: Large inputs consume many tokens. Consider saving to a file and asking the agent to read it.";
},
},
// 3. Thinking level awareness
{
id: "thinking-level-high",
maxShows: 1,
evaluate(ctx) {
const level = ctx.thinkingLevel?.toLowerCase();
if (level !== "high" && level !== "xhigh") return null;
// Only fire for short, simple-looking inputs (likely simple questions)
const trimmed = ctx.input.trim();
if (trimmed.length > 80 || trimmed.includes("\n")) return null;
// Don't fire on slash or bash commands
if (trimmed.startsWith("/") || trimmed.startsWith("!")) return null;
return `Tip: Thinking is set to ${level}. Use Ctrl+T to lower it for simple questions — saves tokens.`;
},
},
// 4. Double-bang reminder
{
id: "double-bang-reminder",
maxShows: 2,
evaluate(ctx) {
// Fire after user has run 3+ included (!) bash commands
if (ctx.bashIncludedCount < 3) return null;
// Only trigger on a ! command (not !!)
const trimmed = ctx.input.trimStart();
if (!trimmed.startsWith("!") || trimmed.startsWith("!!")) return null;
return "Tip: Use !! instead of ! to keep command output out of agent context and save tokens.";
},
},
// 5. Compaction nudge
{
id: "compaction-nudge",
maxShows: 1,
evaluate(ctx) {
if (ctx.contextPercent === undefined || ctx.contextPercent < 70) return null;
// Don't nag on slash/bash
const trimmed = ctx.input.trimStart();
if (trimmed.startsWith("/") || trimmed.startsWith("!")) return null;
return "Tip: Context is getting full. Use /compact to summarize the conversation and free up space.";
},
},
];
// ─── Session-scoped tracker ──────────────────────────────────────────────────
export class ContextualTips {
/** Map of tip ID → number of times shown this session */
private showCounts = new Map<string, number>();
/** Track ! bash commands for double-bang reminder */
private _bashIncludedCount = 0;
/** Increment the bash-included counter. Call when user runs ! (not !!) command. */
recordBashIncluded(): void {
this._bashIncludedCount++;
}
get bashIncludedCount(): number {
return this._bashIncludedCount;
}
/**
* Evaluate all tips against the current input context.
* Returns the first matching tip message, or null if none apply.
*/
evaluate(ctx: Omit<TipContext, "bashIncludedCount">): string | null {
const fullCtx: TipContext = {
...ctx,
bashIncludedCount: this._bashIncludedCount,
};
for (const tip of TIPS) {
const shown = this.showCounts.get(tip.id) ?? 0;
if (shown >= tip.maxShows) continue;
const message = tip.evaluate(fullCtx);
if (message) {
this.showCounts.set(tip.id, shown + 1);
return message;
}
}
return null;
}
/** Reset all counters (e.g. on new session). */
reset(): void {
this.showCounts.clear();
this._bashIncludedCount = 0;
}
}

View file

@ -66,3 +66,5 @@ export {
type TurnStartEvent, type TurnStartEvent,
wrapToolsWithExtensions, wrapToolsWithExtensions,
} from "./extensions/index.js"; } from "./extensions/index.js";
export { ContextualTips, type TipContext } from "./contextual-tips.js";

View file

@ -1,17 +1,21 @@
import { dispatchSlashCommand } from "../slash-command-handlers.js"; import { dispatchSlashCommand } from "../slash-command-handlers.js";
import type { InteractiveModeStateHost } from "../interactive-mode-state.js"; import type { InteractiveModeStateHost } from "../interactive-mode-state.js";
import type { ContextualTips } from "../../../core/contextual-tips.js";
export function setupEditorSubmitHandler(host: InteractiveModeStateHost & { export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
getSlashCommandContext: () => any; getSlashCommandContext: () => any;
handleBashCommand: (command: string, excludeFromContext?: boolean) => Promise<void>; handleBashCommand: (command: string, excludeFromContext?: boolean) => Promise<void>;
showWarning: (message: string) => void; showWarning: (message: string) => void;
showError: (message: string) => void; showError: (message: string) => void;
showTip: (message: string) => void;
updateEditorBorderColor: () => void; updateEditorBorderColor: () => void;
isExtensionCommand: (text: string) => boolean; isExtensionCommand: (text: string) => boolean;
isKnownSlashCommand: (text: string) => boolean; isKnownSlashCommand: (text: string) => boolean;
queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void; queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void;
updatePendingMessagesDisplay: () => void; updatePendingMessagesDisplay: () => void;
flushPendingBashComponents: () => void; flushPendingBashComponents: () => void;
contextualTips: ContextualTips;
getContextPercent: () => number | undefined;
options?: { submitPromptsDirectly?: boolean }; options?: { submitPromptsDirectly?: boolean };
}): void { }): void {
host.defaultEditor.onSubmit = async (text: string) => { host.defaultEditor.onSubmit = async (text: string) => {
@ -41,6 +45,10 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
host.editor.setText(text); host.editor.setText(text);
return; return;
} }
// Track included bash commands for double-bang tip
if (!isExcluded) {
host.contextualTips.recordBashIncluded();
}
host.editor.addToHistory?.(text); host.editor.addToHistory?.(text);
await host.handleBashCommand(command, isExcluded); await host.handleBashCommand(command, isExcluded);
host.isBashMode = false; host.isBashMode = false;
@ -49,6 +57,17 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
} }
} }
// Evaluate contextual tips before sending to agent
const tip = host.contextualTips.evaluate({
input: text,
isStreaming: host.session.isStreaming,
thinkingLevel: host.session.thinkingLevel,
contextPercent: host.getContextPercent(),
});
if (tip) {
host.showTip(tip);
}
if (host.session.isCompacting) { if (host.session.isCompacting) {
if (host.isExtensionCommand(text)) { if (host.isExtensionCommand(text)) {
host.editor.addToHistory?.(text); host.editor.addToHistory?.(text);

View file

@ -90,6 +90,7 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js"; import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { ContextualTips } from "../../core/contextual-tips.js";
import { type SlashCommandContext, dispatchSlashCommand, getAppKeyDisplay } from "./slash-command-handlers.js"; import { type SlashCommandContext, dispatchSlashCommand, getAppKeyDisplay } from "./slash-command-handlers.js";
import { handleAgentEvent } from "./controllers/chat-controller.js"; import { handleAgentEvent } from "./controllers/chat-controller.js";
import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js"; import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js";
@ -214,6 +215,9 @@ export class InteractiveMode {
// Track if editor is in bash mode (text starts with !) // Track if editor is in bash mode (text starts with !)
private isBashMode = false; private isBashMode = false;
// Contextual tips — session-scoped, non-intrusive hints
private contextualTips = new ContextualTips();
// Track current bash execution component // Track current bash execution component
private bashComponent: BashExecutionComponent | undefined = undefined; private bashComponent: BashExecutionComponent | undefined = undefined;
@ -2593,6 +2597,16 @@ export class InteractiveMode {
this.ui.requestRender(); this.ui.requestRender();
} }
showTip(message: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `💡 ${message}`), 1, 0));
this.ui.requestRender();
}
getContextPercent(): number | undefined {
return this.session.getContextUsage()?.percent ?? undefined;
}
showNewVersionNotification(newVersion: string): void { showNewVersionNotification(newVersion: string): void {
const action = theme.fg("accent", getUpdateInstruction("@gsd/pi-coding-agent")); const action = theme.fg("accent", getUpdateInstruction("@gsd/pi-coding-agent"));
const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. `) + action; const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. `) + action;
@ -3679,6 +3693,9 @@ export class InteractiveMode {
this.streamingMessage = undefined; this.streamingMessage = undefined;
this.pendingTools.clear(); this.pendingTools.clear();
// Reset contextual tips for the new session
this.contextualTips.reset();
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender(); this.ui.requestRender();

View file

@ -65,6 +65,7 @@ import type {
SessionManageResponse, SessionManageResponse,
} from "./session-browser-contract" } from "./session-browser-contract"
import { authFetch, appendAuthParam } from "./auth" import { authFetch, appendAuthParam } from "./auth"
import { ContextualTips } from "../../packages/pi-coding-agent/src/core/contextual-tips.ts"
export type WorkspaceStatus = "idle" | "loading" | "ready" | "error" | "unauthenticated" export type WorkspaceStatus = "idle" | "loading" | "ready" | "error" | "unauthenticated"
export type WorkspaceConnectionState = export type WorkspaceConnectionState =
@ -1855,6 +1856,7 @@ export class GSDWorkspaceStore {
private state = createInitialState() private state = createInitialState()
private readonly listeners = new Set<() => void>() private readonly listeners = new Set<() => void>()
private readonly contextualTips = new ContextualTips()
private bootPromise: Promise<void> | null = null private bootPromise: Promise<void> | null = null
private eventSource: EventSource | null = null private eventSource: EventSource | null = null
private onboardingPollTimer: ReturnType<typeof setInterval> | null = null private onboardingPollTimer: ReturnType<typeof setInterval> | null = null
@ -4032,6 +4034,26 @@ export class GSDWorkspaceStore {
lastSlashCommandOutcome: trimmed.startsWith("/") ? outcome : null, lastSlashCommandOutcome: trimmed.startsWith("/") ? outcome : null,
}) })
// Evaluate contextual tips before sending to agent
if (outcome.kind === "prompt") {
const sessionState = this.state.boot?.bridge.sessionState
const tip = this.contextualTips.evaluate({
input: trimmed,
isStreaming: Boolean(sessionState?.isStreaming),
thinkingLevel: sessionState?.thinkingLevel,
// contextPercent not available in web — compaction nudge won't fire here
contextPercent: undefined,
})
if (tip) {
this.patchState({
terminalLines: withTerminalLine(
this.state.terminalLines,
createTerminalLine("system", `💡 ${tip}`),
),
})
}
}
switch (outcome.kind) { switch (outcome.kind) {
case "prompt": case "prompt":
case "rpc": { case "rpc": {
@ -4674,6 +4696,11 @@ export class GSDWorkspaceStore {
}) })
} }
// Reset contextual tips on new session
if (payload.command === "new_session" && payload.success) {
this.contextualTips.reset()
}
if (payload.code === "onboarding_locked" && payload.details?.onboarding && this.state.boot) { if (payload.code === "onboarding_locked" && payload.details?.onboarding && this.state.boot) {
this.patchState({ this.patchState({
boot: cloneBootWithPartialOnboarding(this.state.boot, payload.details.onboarding), boot: cloneBootWithPartialOnboarding(this.state.boot, payload.details.onboarding),