From 47bedc55402dc8a6c27a9f9db9ae740e87e7aabf Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 23 Mar 2026 14:17:20 -0500 Subject: [PATCH] feat: add contextual tips system for TUI and web terminal Add a session-scoped contextual tips system that shows non-intrusive hints when user behavior suggests they'd benefit from knowing a feature. Tips: - Shell command prefix: nudge when bare ls/git/npm typed without ! - Large paste: warn when >2000 char input sent to agent - Thinking level: hint when short question with high/xhigh thinking - Double-bang reminder: after 3+ single-! commands, suggest !! - Compaction nudge: when context >= 70% full Each tip fires at most N times per session, resets on /new. Wired into both TUI (dim inline text) and web terminal (system line). 31 unit tests covering all tips, suppression, reset, and priority. --- .../src/core/contextual-tips.test.ts | 259 ++++++++++++++++++ .../src/core/contextual-tips.ts | 232 ++++++++++++++++ packages/pi-coding-agent/src/core/index.ts | 2 + .../controllers/input-controller.ts | 19 ++ .../src/modes/interactive/interactive-mode.ts | 17 ++ web/lib/gsd-workspace-store.tsx | 27 ++ 6 files changed, 556 insertions(+) create mode 100644 packages/pi-coding-agent/src/core/contextual-tips.test.ts create mode 100644 packages/pi-coding-agent/src/core/contextual-tips.ts diff --git a/packages/pi-coding-agent/src/core/contextual-tips.test.ts b/packages/pi-coding-agent/src/core/contextual-tips.test.ts new file mode 100644 index 000000000..29341e659 --- /dev/null +++ b/packages/pi-coding-agent/src/core/contextual-tips.test.ts @@ -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")); + }); + }); +}); diff --git a/packages/pi-coding-agent/src/core/contextual-tips.ts b/packages/pi-coding-agent/src/core/contextual-tips.ts new file mode 100644 index 000000000..d3ac27ec6 --- /dev/null +++ b/packages/pi-coding-agent/src/core/contextual-tips.ts @@ -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 (0–100), 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(); + /** 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): 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; + } +} diff --git a/packages/pi-coding-agent/src/core/index.ts b/packages/pi-coding-agent/src/core/index.ts index 10c6f1753..5429a9f57 100644 --- a/packages/pi-coding-agent/src/core/index.ts +++ b/packages/pi-coding-agent/src/core/index.ts @@ -60,3 +60,5 @@ export { type TurnStartEvent, wrapToolsWithExtensions, } from "./extensions/index.js"; + +export { ContextualTips, type TipContext } from "./contextual-tips.js"; diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts index 0bb073044..6c318fe50 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -1,16 +1,20 @@ import { dispatchSlashCommand } from "../slash-command-handlers.js"; import type { InteractiveModeStateHost } from "../interactive-mode-state.js"; +import type { ContextualTips } from "../../../core/contextual-tips.js"; export function setupEditorSubmitHandler(host: InteractiveModeStateHost & { getSlashCommandContext: () => any; handleBashCommand: (command: string, excludeFromContext?: boolean) => Promise; showWarning: (message: string) => void; showError: (message: string) => void; + showTip: (message: string) => void; updateEditorBorderColor: () => void; isExtensionCommand: (text: string) => boolean; queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void; updatePendingMessagesDisplay: () => void; flushPendingBashComponents: () => void; + contextualTips: ContextualTips; + getContextPercent: () => number | undefined; options?: { submitPromptsDirectly?: boolean }; }): void { host.defaultEditor.onSubmit = async (text: string) => { @@ -34,6 +38,10 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & { host.editor.setText(text); return; } + // Track included bash commands for double-bang tip + if (!isExcluded) { + host.contextualTips.recordBashIncluded(); + } host.editor.addToHistory?.(text); await host.handleBashCommand(command, isExcluded); host.isBashMode = false; @@ -42,6 +50,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.isExtensionCommand(text)) { host.editor.addToHistory?.(text); diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 2f0beb331..4059b2fde 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -89,6 +89,7 @@ import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.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 { handleAgentEvent } from "./controllers/chat-controller.js"; import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js"; @@ -205,6 +206,9 @@ export class InteractiveMode { // Track if editor is in bash mode (text starts with !) private isBashMode = false; + // Contextual tips — session-scoped, non-intrusive hints + private contextualTips = new ContextualTips(); + // Track current bash execution component private bashComponent: BashExecutionComponent | undefined = undefined; @@ -2545,6 +2549,16 @@ export class InteractiveMode { 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 { const action = theme.fg("accent", getUpdateInstruction("@gsd/pi-coding-agent")); const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. `) + action; @@ -3615,6 +3629,9 @@ export class InteractiveMode { this.streamingMessage = undefined; this.pendingTools.clear(); + // Reset contextual tips for the new session + this.contextualTips.reset(); + this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.ui.requestRender(); diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index a912c4217..7fd60d48b 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -65,6 +65,7 @@ import type { SessionManageResponse, } from "./session-browser-contract" import { authFetch, appendAuthParam } from "./auth" +import { ContextualTips } from "../../packages/pi-coding-agent/src/core/contextual-tips.ts" export type WorkspaceStatus = "idle" | "loading" | "ready" | "error" export type WorkspaceConnectionState = @@ -1845,6 +1846,7 @@ export class GSDWorkspaceStore { private state = createInitialState() private readonly listeners = new Set<() => void>() + private readonly contextualTips = new ContextualTips() private bootPromise: Promise | null = null private eventSource: EventSource | null = null private onboardingPollTimer: ReturnType | null = null @@ -4022,6 +4024,26 @@ export class GSDWorkspaceStore { 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) { case "prompt": case "rpc": { @@ -4657,6 +4679,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) { this.patchState({ boot: cloneBootWithPartialOnboarding(this.state.boot, payload.details.onboarding),