Merge pull request #2281 from jeremymcs/worktree-local-commands-stay-local
feat: contextual tips
This commit is contained in:
commit
da352847e2
6 changed files with 556 additions and 0 deletions
259
packages/pi-coding-agent/src/core/contextual-tips.test.ts
Normal file
259
packages/pi-coding-agent/src/core/contextual-tips.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
232
packages/pi-coding-agent/src/core/contextual-tips.ts
Normal file
232
packages/pi-coding-agent/src/core/contextual-tips.ts
Normal 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 (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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -66,3 +66,5 @@ export {
|
|||
type TurnStartEvent,
|
||||
wrapToolsWithExtensions,
|
||||
} from "./extensions/index.js";
|
||||
|
||||
export { ContextualTips, type TipContext } from "./contextual-tips.js";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
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<void>;
|
||||
showWarning: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
showTip: (message: string) => void;
|
||||
updateEditorBorderColor: () => void;
|
||||
isExtensionCommand: (text: string) => boolean;
|
||||
isKnownSlashCommand: (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) => {
|
||||
|
|
@ -41,6 +45,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;
|
||||
|
|
@ -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.isExtensionCommand(text)) {
|
||||
host.editor.addToHistory?.(text);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,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";
|
||||
|
|
@ -214,6 +215,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;
|
||||
|
||||
|
|
@ -2593,6 +2597,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;
|
||||
|
|
@ -3679,6 +3693,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();
|
||||
|
|
|
|||
|
|
@ -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" | "unauthenticated"
|
||||
export type WorkspaceConnectionState =
|
||||
|
|
@ -1855,6 +1856,7 @@ export class GSDWorkspaceStore {
|
|||
|
||||
private state = createInitialState()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private readonly contextualTips = new ContextualTips()
|
||||
private bootPromise: Promise<void> | null = null
|
||||
private eventSource: EventSource | null = null
|
||||
private onboardingPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
|
@ -4032,6 +4034,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": {
|
||||
|
|
@ -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) {
|
||||
this.patchState({
|
||||
boot: cloneBootWithPartialOnboarding(this.state.boot, payload.details.onboarding),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue