From 9e54ddf6ea9ca970666ddd8d5e96cf35798ee1c9 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 17 Jul 2026 11:11:33 -0700 Subject: [PATCH] add subtle feature hints (#452) * feat(coding-agent): add feature hints * feat(coding-agent): expand feature hints * feat(coding-agent): broaden feature hint catalog * refactor(coding-agent): simplify feature hints * refactor(coding-agent): clarify feature hints * feat(coding-agent): hint automatic agent features * refactor(coding-agent): focus automatic feature hints * refactor(coding-agent): clarify self-improvement hint * refactor(coding-agent): sharpen self-improvement hint * feat(coding-agent): animate feature hints * fix(tui): align autocomplete popup * refactor(coding-agent): polish hint shimmer * refactor(coding-agent): refine hint glint * refactor(coding-agent): soften hint glint * refactor(coding-agent): tune hint glint * fix(coding-agent): preserve delayed hints * fix(tui): preserve cramped autocomplete * fix(tui): trim cramped popup padding * refactor(coding-agent): soften hint shimmer * test(coding-agent): cover hint reset cleanup * fix(coding-agent): preserve hints through compaction * fix(tui): retain top-edge autocomplete * fix(coding-agent): classify hint run starts --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/feature-hint.ts | 46 ++++ .../src/modes/interactive/feature-hints.ts | 136 +++++++++++ .../src/modes/interactive/interactive-mode.ts | 111 +++++++++ .../interactive-mode-feature-hints.test.ts | 230 ++++++++++++++++++ .../test/interactive-mode-status.test.ts | 3 + packages/tui/CHANGELOG.md | 2 + packages/tui/src/components/editor.ts | 1 + packages/tui/src/tui.ts | 21 +- .../test/editor-autocomplete-overlay.test.ts | 24 +- packages/tui/test/editor.test.ts | 30 +++ packages/tui/test/overlay-options.test.ts | 33 +++ 12 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/feature-hint.ts create mode 100644 packages/coding-agent/src/modes/interactive/feature-hints.ts create mode 100644 packages/coding-agent/test/interactive-mode-feature-hints.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 223ff79a5..f17798b17 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ - Changed top-level CLI help to show concise common options and commands without loading runtime resources ([ENG-4688](https://linear.app/primeintellect/issue/ENG-4688/help-command-is-obscenely-verbose)). - Fixed completed subagents cancelling their RLM heartbeats before the first run ([ENG-4652](https://linear.app/primeintellect/issue/ENG-4652/subagent-heartbeats-dont-work)). - Changed the fullscreen follow shortcut from `Alt+Down` to `Ctrl+Shift+Down` for more reliable terminal input ([ENG-4684](https://linear.app/primeintellect/issue/ENG-4684/altdown-doesnt-work)). +- Added subtle feature hints to longer-running agent turns ([ENG-4521](https://linear.app/primeintellect/issue/ENG-4521/add-subtle-hints-for-new-prime-agent-features)). - Fixed active heartbeats not resuming after Prime Agent updates ([ENG-4657](https://linear.app/primeintellect/issue/ENG-4657/heartbeats-dont-survive-updatesdaemon-reboots)). - Fixed the Agents View reordering sessions whenever prompts or heartbeats updated their activity timestamps ([ENG-4650](https://linear.app/primeintellect/issue/ENG-4650/agents-view-shifts-session-list-constantly)). - Added parent-scoped subagent lifecycle APIs: create children with readable default or orchestrator-chosen names, recover running or completed children through `rlm.list_subagents()`, continue them through agent messaging, and close/remove them with `rlm.delete_subagent()`. diff --git a/packages/coding-agent/src/modes/interactive/components/feature-hint.ts b/packages/coding-agent/src/modes/interactive/components/feature-hint.ts new file mode 100644 index 000000000..acbe3aa42 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/feature-hint.ts @@ -0,0 +1,46 @@ +import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.js"; + +const LABEL = "Hint:"; +const SHIMMER_RADIUS = 1; +const SHIMMER_PAUSE_FRAMES = 14; + +export const FEATURE_HINT_ANIMATION_INTERVAL_MS = 160; + +function renderLabelShimmer(characters: string[], frame: number): string { + if (characters.length === 0) return ""; + + const travelFrames = characters.length + SHIMMER_RADIUS * 2; + const phase = frame % (travelFrames + SHIMMER_PAUSE_FRAMES); + const center = phase - SHIMMER_RADIUS; + return characters + .map((character, index) => + theme.fg(Math.abs(index - center) <= SHIMMER_RADIUS ? "muted" : "thinkingText", character), + ) + .join(""); +} + +export class FeatureHintComponent implements Component { + private frame = 0; + + constructor(private readonly text: string) {} + + advance(): void { + this.frame++; + } + + invalidate(): void {} + + render(width: number): string[] { + const paddingX = width > 2 ? 1 : 0; + const availableWidth = Math.max(1, width - paddingX * 2); + const displayText = truncateToWidth(`${LABEL} ${this.text}`, availableWidth); + const characters = Array.from(displayText); + const labelLength = Math.min(LABEL.length, characters.length); + const label = renderLabelShimmer(characters.slice(0, labelLength), this.frame); + const hint = theme.fg("muted", characters.slice(labelLength).join("")); + const line = `${" ".repeat(paddingX)}${label}${hint}`; + + return [line + " ".repeat(Math.max(0, width - visibleWidth(line))), " ".repeat(width)]; + } +} diff --git a/packages/coding-agent/src/modes/interactive/feature-hints.ts b/packages/coding-agent/src/modes/interactive/feature-hints.ts new file mode 100644 index 000000000..6f15fa227 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/feature-hints.ts @@ -0,0 +1,136 @@ +import type { AppKeybinding } from "../../core/keybindings.js"; + +export interface FeatureHintContext { + getKeybinding(action: AppKeybinding): string | undefined; + isResidentSession: boolean; +} + +interface FeatureHintDefinition { + id: string; + getText(context: FeatureHintContext): string | undefined; +} + +export interface FeatureHint { + id: string; + text: string; +} + +export const FEATURE_HINTS: readonly FeatureHintDefinition[] = [ + { + id: "side-question", + getText: () => "Use /btw to ask questions without interrupting your agent.", + }, + { + id: "prompt-stash", + getText: ({ getKeybinding }) => { + const key = getKeybinding("app.prompt.stash"); + return key ? `Press ${key} to stash your prompt and restore it later.` : undefined; + }, + }, + { + id: "follow-up", + getText: ({ getKeybinding }) => { + const key = getKeybinding("app.message.followUp"); + return key ? `Press ${key} to send a message after your agent finishes.` : undefined; + }, + }, + { + id: "heartbeat", + getText: () => "Use /heartbeat every 10m to repeat a task on a schedule.", + }, + { + id: "subagents", + getText: () => "Prime Agent can delegate tasks to subagents and run them in parallel.", + }, + { + id: "agents-view", + getText: ({ getKeybinding, isResidentSession }) => { + if (!isResidentSession) return undefined; + const key = getKeybinding("app.agents.back"); + return key ? `Hit ${key} for Agents View, where you can manage all your running agents.` : undefined; + }, + }, + { + id: "session-rewind", + getText: () => "Run /tree to open the session tree and return to a previous message.", + }, + { + id: "steering", + getText: () => "Send a message while your agent works to steer its current task.", + }, + { + id: "agent-messaging", + getText: () => "Agents can message each other to share context and coordinate their work.", + }, + { + id: "goal", + getText: () => "Use /goal to keep your agent working until the goal is complete.", + }, + { + id: "refine", + getText: () => "Use /refine to turn useful lessons into reusable skills, memory, and prompts.", + }, + { + id: "persistent-ipython", + getText: () => "Prime Agent keeps IPython variables and helpers between turns and compactions.", + }, + { + id: "context-usage", + getText: () => "Use /context to check token usage, cost, and remaining context.", + }, + { + id: "session-fork", + getText: () => "Use /fork to start a new session from an earlier prompt.", + }, + { + id: "compaction", + getText: () => "Use /compact to summarize old messages and free up context.", + }, + { + id: "auto-compaction", + getText: () => "Prime Agent automatically compacts long sessions before context fills up.", + }, + { + id: "auto-refine", + getText: () => "Prime Agent self-improves by refining skills, memories, prompts, and subagents.", + }, + { + id: "background-running", + getText: ({ isResidentSession }) => + isResidentSession ? "You can close the terminal while your agent keeps running in the background." : undefined, + }, +] as const; + +export class FeatureHintDeck { + private remaining: FeatureHint[] = []; + private previousId: string | undefined; + + constructor(private readonly random: () => number = Math.random) {} + + next(context: FeatureHintContext): FeatureHint | undefined { + if (this.remaining.length === 0) { + this.refill(context); + } + const hint = this.remaining.pop(); + if (hint) { + this.previousId = hint.id; + } + return hint; + } + + private refill(context: FeatureHintContext): void { + const hints = FEATURE_HINTS.flatMap((hint) => { + const text = hint.getText(context); + return text ? [{ id: hint.id, text }] : []; + }); + for (let index = hints.length - 1; index > 0; index--) { + const random = Math.min(0.999999999, Math.max(0, this.random())); + const target = Math.floor(random * (index + 1)); + [hints[index], hints[target]] = [hints[target]!, hints[index]!]; + } + if (hints.length > 1 && hints[hints.length - 1]?.id === this.previousId) { + [hints[0], hints[hints.length - 1]] = [hints[hints.length - 1]!, hints[0]!]; + } + this.remaining = hints; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 02b35c0d9..967a743a8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -101,6 +101,7 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.j import { createCompactionSummaryMessage, createHeartbeatPromptMessage, + HEARTBEAT_PROMPT_CUSTOM_TYPE, HEARTBEAT_PROMPT_PREVIEW_LABEL, } from "../../core/messages.js"; import { findExactModelReferenceMatch, resolveModelScopeFromModels } from "../../core/model-resolver.js"; @@ -183,6 +184,7 @@ import { type FileChangeSummary, formatTotalChangeSummary, mergeTurnFileChanges import { ExtensionEditorComponent } from "./components/extension-editor.js"; import { ExtensionInputComponent } from "./components/extension-input.js"; import { ExtensionSelectorComponent } from "./components/extension-selector.js"; +import { FEATURE_HINT_ANIMATION_INTERVAL_MS, FeatureHintComponent } from "./components/feature-hint.js"; import { FooterComponent } from "./components/footer.js"; import { HeartbeatManagerComponent } from "./components/heartbeat-manager.js"; import { InjectedPromptMessageComponent, isInjectedPromptMessage } from "./components/injected-prompt-message.js"; @@ -203,6 +205,7 @@ import { import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; +import { FeatureHintDeck } from "./feature-hints.js"; import { collectMarkedImages, evictImagesToBudget, formatImageMarker, imageMarkerIds } from "./image-markers.js"; import type { InteractiveModeLocalSessionHost, @@ -244,6 +247,7 @@ interface PendingToolCallRenderInput { const HEARTBEAT_LEGACY_PROMPT_MIN_TOLERANCE_MS = 15_000; const HEARTBEAT_LEGACY_PROMPT_MAX_TOLERANCE_MS = 120_000; const MODEL_CATALOG_REFRESH_TTL_MS = 60_000; +const FEATURE_HINT_DELAY_MS = 5_000; export const START_HINTS = [ 'Try "refactor @"', @@ -703,6 +707,7 @@ export class InteractiveMode { private statusContainer: Container; private queuedMessagesContainer: Container; private sideQuestionContainer: Container; + private featureHintContainer: Container; private defaultEditor: CustomEditor; private editor: EditorComponent; private readonly promptStashStore: ClientPromptStashStore | undefined; @@ -735,6 +740,13 @@ export class InteractiveMode { private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; private workingStartedAt: number | undefined = undefined; private workingTimer: NodeJS.Timeout | undefined = undefined; + private readonly featureHintDeck = new FeatureHintDeck(); + private currentFeatureHint: string | undefined; + private featureHintEligibleAt = 0; + private featureHintTimer: NodeJS.Timeout | undefined; + private featureHintAnimationTimer: NodeJS.Timeout | undefined; + private featureHintComponent: FeatureHintComponent | undefined; + private featureHintRunPending = false; private pulseTimer: NodeJS.Timeout | undefined = undefined; private pulseFrame = 0; private readonly activityTracker = new AgentActivityTracker(); @@ -928,6 +940,7 @@ export class InteractiveMode { this.statusContainer = new Container(); this.queuedMessagesContainer = new Container(); this.sideQuestionContainer = new Container(); + this.featureHintContainer = new Container(); this.childAgentDetail = new ChildAgentDetailComponent(() => this.getChildAgentPanelRows(), { ui: this.ui, }); @@ -1262,12 +1275,14 @@ export class InteractiveMode { this.mainContainer.addChild(this.recapContainer); this.mainContainer.addChild(this.queuedMessagesContainer); this.mainContainer.addChild(this.sideQuestionContainer); + this.mainContainer.addChild(this.featureHintContainer); this.mainContainer.addChild(this.editorContainer); this.mainContainer.addChild(this.childAgentSummary); this.mainContainer.addChild(this.widgetContainerBelow); this.footerSlot.addChild(this.footer); this.mainContainer.addChild(this.footerSlot); this.promptDock.addChild(this.sideQuestionContainer); + this.promptDock.addChild(this.featureHintContainer); this.promptDock.addChild(this.editorContainer); this.promptDock.addChild(this.childAgentSummary); this.promptDock.addChild(this.footerSlot); @@ -2578,6 +2593,7 @@ export class InteractiveMode { } private resetCurrentSessionRenderState(options?: { clearPromptStash?: boolean }): void { + this.endFeatureHintRun(); this.chatContainer.clear(); this.shortcutGuideContainer.clear(); this.pendingMessagesContainer.clear(); @@ -2921,9 +2937,11 @@ export class InteractiveMode { this.loadingAnimation = this.createWorkingLoader(); this.statusContainer.addChild(this.loadingAnimation); this.startWorkingTimer(); + this.startFeatureHintPresentation(); } private stopWorkingLoader(): void { + this.clearFeatureHintPresentation(); if (this.workingTimer) { clearInterval(this.workingTimer); this.workingTimer = undefined; @@ -2936,6 +2954,94 @@ export class InteractiveMode { this.statusContainer.clear(); } + private startFeatureHintPresentation(): void { + this.clearFeatureHintPresentation(); + if (this.featureHintEligibleAt === 0) { + this.featureHintEligibleAt = Date.now() + FEATURE_HINT_DELAY_MS; + } + const delay = Math.max(0, this.featureHintEligibleAt - Date.now()); + if (delay === 0) { + this.showFeatureHint(); + return; + } + this.featureHintTimer = setTimeout(() => { + this.featureHintTimer = undefined; + this.showFeatureHint(); + }, delay); + this.featureHintTimer.unref?.(); + } + + private showFeatureHint(): void { + if ( + !this.loadingAnimation || + !this.shouldShowWorkingLoader() || + !this.statusContainer.children.includes(this.loadingAnimation) + ) { + return; + } + if (!this.currentFeatureHint) { + const hint = this.featureHintDeck.next({ + getKeybinding: (action) => { + const key = keyText(action); + return key ? this.capitalizeKey(key) : undefined; + }, + isResidentSession: this.options.returnToAgentsView === true, + }); + this.currentFeatureHint = hint?.text; + } + if (!this.currentFeatureHint) { + return; + } + this.featureHintComponent = new FeatureHintComponent(this.currentFeatureHint); + this.featureHintContainer.addChild(this.featureHintComponent); + this.featureHintAnimationTimer = setInterval(() => { + this.featureHintComponent?.advance(); + this.ui.requestRender(); + }, FEATURE_HINT_ANIMATION_INTERVAL_MS); + this.featureHintAnimationTimer.unref?.(); + this.ui.requestRender(); + } + + private clearFeatureHintPresentation(): void { + if (this.featureHintTimer) { + clearTimeout(this.featureHintTimer); + this.featureHintTimer = undefined; + } + if (this.featureHintAnimationTimer) { + clearInterval(this.featureHintAnimationTimer); + this.featureHintAnimationTimer = undefined; + } + if (this.featureHintComponent) { + this.featureHintContainer.removeChild(this.featureHintComponent); + this.featureHintComponent = undefined; + } + } + + private endFeatureHintRun(): void { + this.clearFeatureHintPresentation(); + this.currentFeatureHint = undefined; + this.featureHintEligibleAt = 0; + this.featureHintRunPending = false; + } + + private prepareFeatureHintRun(message: AgentMessage): void { + if (!this.featureHintRunPending) return; + if (message.role === "assistant") { + this.featureHintRunPending = false; + return; + } + const startsNewRun = + message.role === "user" || + isAgentSessionMessage(message) || + (message.role === "custom" && message.customType === HEARTBEAT_PROMPT_CUSTOM_TYPE); + if (!startsNewRun) return; + + this.endFeatureHintRun(); + if (this.shouldShowWorkingLoader()) { + this.startFeatureHintPresentation(); + } + } + private updateWorkingPulse(): void { const active = this.isAgentStreaming() || this.countRunningChildAgents() > 0; if (!active) { @@ -4562,6 +4668,9 @@ export class InteractiveMode { this.updateConnectionStateFromEvent(event); // A new user message resets the activity tracker to 0, so the in-flight baseline must // reset with it. (agent_start on auto-retry does not reset the tracker.) + if (event.type === "message_start") { + this.prepareFeatureHintRun(event.message); + } if (event.type === "message_start" && (event.message.role === "user" || isAgentSessionMessage(event.message))) { this.contextUsageTokenBaseline = 0; this.setSessionHasMessages(true); @@ -4574,6 +4683,7 @@ export class InteractiveMode { switch (event.type) { case "agent_start": + this.featureHintRunPending = this.getRetryAttempt() === 0; this.resetPendingToolState(); this.renderRecap(); if (this.settingsManager.getShowTerminalProgress()) { @@ -9236,6 +9346,7 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}${shor this.ui.terminal.setProgress(false); } this.stopWorkingLoader(); + this.endFeatureHintRun(); this.stopWorkingPulse(); this.stopGoalTrayTimer(); this.closeHeartbeatManager(); diff --git a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts new file mode 100644 index 000000000..d5d12b787 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts @@ -0,0 +1,230 @@ +import { type Component, Container, visibleWidth } from "@earendil-works/pi-tui"; +import stripAnsi from "strip-ansi"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { FEATURE_HINT_ANIMATION_INTERVAL_MS } from "../src/modes/interactive/components/feature-hint.js"; +import { FEATURE_HINTS, FeatureHintDeck } from "../src/modes/interactive/feature-hints.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +class FakeLoader implements Component { + readonly stop = vi.fn(); + + invalidate(): void {} + + render(width: number): string[] { + return ["loader".padEnd(width)]; + } +} + +function callPrivate(mode: object, name: string): void { + Reflect.get(InteractiveMode.prototype, name).call(mode); +} + +function createMode() { + const statusContainer = new Container(); + const featureHintContainer = new Container(); + const loader = new FakeLoader(); + statusContainer.addChild(loader); + const hint = { id: "test", text: "This is a deliberately long feature hint for narrow terminals." }; + const featureHintDeck = { next: vi.fn(() => hint) }; + const requestRender = vi.fn(); + const mode = { + statusContainer, + featureHintContainer, + loadingAnimation: loader, + workingVisible: true, + connectionState: { isStreaming: true }, + workingTimer: undefined, + workingStartedAt: 0, + featureHintDeck, + currentFeatureHint: undefined, + featureHintEligibleAt: 0, + featureHintTimer: undefined, + featureHintAnimationTimer: undefined, + featureHintComponent: undefined, + featureHintRunPending: false, + options: { returnToAgentsView: true }, + ui: { requestRender }, + }; + Object.setPrototypeOf(mode, InteractiveMode.prototype); + return { mode, statusContainer, featureHintContainer, loader, featureHintDeck, requestRender }; +} + +describe("feature hint deck", () => { + it("shows every available hint before repeating and avoids a boundary repeat", () => { + const deck = new FeatureHintDeck(() => 0); + const context = { getKeybinding: (action: string) => `Custom ${action}`, isResidentSession: true }; + const firstCycle = FEATURE_HINTS.map(() => deck.next(context)); + + expect(firstCycle.every((hint) => hint !== undefined)).toBe(true); + expect(new Set(firstCycle.map((hint) => hint?.id)).size).toBe(FEATURE_HINTS.length); + expect(deck.next(context)?.id).not.toBe(firstCycle.at(-1)?.id); + }); + + it("uses configured shortcuts in keybinding-based hints", () => { + const deck = new FeatureHintDeck(() => 0); + const context = { + getKeybinding: (action: string) => { + if (action === "app.prompt.stash") return "Meta+S"; + if (action === "app.message.followUp") return "Meta+Enter"; + return "Meta+Left"; + }, + isResidentSession: true, + }; + const hints = FEATURE_HINTS.map(() => deck.next(context)); + + expect(hints.find((hint) => hint?.id === "prompt-stash")?.text).toContain("Meta+S"); + expect(hints.find((hint) => hint?.id === "follow-up")?.text).toContain("Meta+Enter"); + expect(hints.find((hint) => hint?.id === "agents-view")?.text).toContain("Meta+Left"); + }); + + it("covers Prime Agent workflows with capability-focused copy", () => { + const deck = new FeatureHintDeck(() => 0); + const hints = FEATURE_HINTS.map(() => deck.next({ getKeybinding: () => "Meta+A", isResidentSession: true })); + const textById = new Map(hints.map((hint) => [hint?.id, hint?.text])); + + expect(textById.get("subagents")).toBe("Prime Agent can delegate tasks to subagents and run them in parallel."); + expect(textById.get("agents-view")).toContain("Agents View"); + expect(textById.get("session-rewind")).toContain("/tree"); + expect(textById.get("steering")).toContain("steer"); + expect(textById.get("agent-messaging")).toContain("message each other"); + expect(textById.get("goal")).toContain("/goal"); + expect(textById.get("refine")).toContain("/refine"); + expect(textById.get("persistent-ipython")).toContain("IPython"); + expect(textById.get("context-usage")).toContain("/context"); + expect(textById.get("session-fork")).toContain("/fork"); + expect(textById.get("compaction")).toContain("/compact"); + expect(textById.get("auto-compaction")).toContain("automatically compacts"); + expect(textById.get("auto-refine")).toContain("self-improves"); + expect(textById.get("background-running")).toContain("close the terminal"); + }); + + it("keeps every hint concise", () => { + const deck = new FeatureHintDeck(() => 0); + const hints = FEATURE_HINTS.map(() => deck.next({ getKeybinding: () => "Ctrl+Key", isResidentSession: true })); + + expect(hints.every((hint) => hint !== undefined && hint.text.length <= 80)).toBe(true); + }); + + it("hides resident-only hints in ephemeral sessions", () => { + const context = { getKeybinding: () => "Left", isResidentSession: false }; + const textById = new Map(FEATURE_HINTS.map((hint) => [hint.id, hint.getText(context)])); + + expect(textById.get("agents-view")).toBeUndefined(); + expect(textById.get("background-running")).toBeUndefined(); + }); +}); + +describe("InteractiveMode feature hints", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("adds one animated truncated hint above the prompt after a sustained agent run", () => { + const { mode, statusContainer, featureHintContainer, featureHintDeck, requestRender } = createMode(); + + callPrivate(mode, "startFeatureHintPresentation"); + vi.advanceTimersByTime(4_999); + expect(statusContainer.children).toHaveLength(1); + expect(featureHintContainer.children).toHaveLength(0); + expect(featureHintDeck.next).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(statusContainer.children).toHaveLength(1); + expect(featureHintContainer.children).toHaveLength(1); + const lines = featureHintContainer.children[0]?.render(24) ?? []; + expect(lines).toHaveLength(2); + expect(stripAnsi(lines[0] ?? "")).toContain("Hint:"); + expect(lines[1]?.trim()).toBe(""); + expect(visibleWidth(lines[0] ?? "")).toBeLessThanOrEqual(24); + expect(featureHintDeck.next).toHaveBeenCalledTimes(1); + expect(requestRender).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(FEATURE_HINT_ANIMATION_INTERVAL_MS); + const animatedLines = featureHintContainer.children[0]?.render(24) ?? []; + expect(stripAnsi(animatedLines[0] ?? "")).toBe(stripAnsi(lines[0] ?? "")); + expect(animatedLines[0]).not.toBe(lines[0]); + expect(requestRender).toHaveBeenCalledTimes(2); + }); + + it("cancels a pending hint when the loader stops", () => { + const { mode, statusContainer, featureHintContainer, featureHintDeck, requestRender } = createMode(); + + callPrivate(mode, "startFeatureHintPresentation"); + vi.advanceTimersByTime(2_000); + callPrivate(mode, "stopWorkingLoader"); + vi.advanceTimersByTime(5_000); + + expect(statusContainer.children).toHaveLength(0); + expect(featureHintContainer.children).toHaveLength(0); + expect(featureHintDeck.next).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it("retains the hint and remaining delay when the loader is recreated", () => { + const { mode, statusContainer, featureHintContainer, featureHintDeck } = createMode(); + + callPrivate(mode, "startFeatureHintPresentation"); + vi.advanceTimersByTime(3_000); + callPrivate(mode, "stopWorkingLoader"); + + const replacement = new FakeLoader(); + Reflect.set(mode, "loadingAnimation", replacement); + statusContainer.addChild(replacement); + callPrivate(mode, "startFeatureHintPresentation"); + vi.advanceTimersByTime(1_999); + expect(statusContainer.children).toHaveLength(1); + expect(featureHintContainer.children).toHaveLength(0); + expect(featureHintDeck.next).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(statusContainer.children).toHaveLength(1); + expect(featureHintContainer.children).toHaveLength(1); + expect(featureHintDeck.next).toHaveBeenCalledTimes(1); + + callPrivate(mode, "endFeatureHintRun"); + expect(statusContainer.children).toHaveLength(1); + expect(featureHintContainer.children).toHaveLength(0); + expect(Reflect.get(mode, "currentFeatureHint")).toBeUndefined(); + }); + + it("keeps hints for steering and continuations, then restarts the delay for new runs", () => { + const { mode } = createMode(); + Reflect.set(mode, "currentFeatureHint", "Existing hint"); + Reflect.set(mode, "featureHintEligibleAt", 5_000); + + Reflect.get(InteractiveMode.prototype, "prepareFeatureHintRun").call(mode, { + role: "user", + content: [{ type: "text", text: "steer" }], + timestamp: 0, + }); + expect(Reflect.get(mode, "currentFeatureHint")).toBe("Existing hint"); + + Reflect.set(mode, "featureHintRunPending", true); + Reflect.get(InteractiveMode.prototype, "prepareFeatureHintRun").call(mode, { + role: "assistant", + content: [], + }); + expect(Reflect.get(mode, "currentFeatureHint")).toBe("Existing hint"); + expect(Reflect.get(mode, "featureHintRunPending")).toBe(false); + + Reflect.set(mode, "featureHintRunPending", true); + Reflect.get(InteractiveMode.prototype, "prepareFeatureHintRun").call(mode, { + role: "user", + content: [{ type: "text", text: "new run" }], + timestamp: 0, + }); + expect(Reflect.get(mode, "currentFeatureHint")).toBeUndefined(); + expect(Reflect.get(mode, "featureHintEligibleAt")).toBe(5_000); + expect(Reflect.get(mode, "featureHintTimer")).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index b6a5b6e77..d54a3a8d0 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -636,7 +636,9 @@ describe("InteractiveMode pending bash components", () => { expect(loader.intervalId).not.toBeNull(); const editorStub = { clearHistory: vi.fn(), setText: vi.fn() }; + const endFeatureHintRun = vi.fn(); const fakeThis = { + endFeatureHintRun, chatContainer: new Container(), shortcutGuideContainer: new Container(), pendingMessagesContainer: new Container(), @@ -668,6 +670,7 @@ describe("InteractiveMode pending bash components", () => { ).resetCurrentSessionRenderState.call(fakeThis); expect(loader.intervalId).toBeNull(); + expect(endFeatureHintRun).toHaveBeenCalledOnce(); expect((fakeThis as unknown as { activeBashComponent: unknown }).activeBashComponent).toBeUndefined(); }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index bb38170dc..997b4e5cd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] - Changed the fullscreen follow shortcut from `Alt+Down` to `Ctrl+Shift+Down` for more reliable terminal input ([ENG-4684](https://linear.app/primeintellect/issue/ENG-4684/altdown-doesnt-work)). +- Fixed autocomplete popups overlapping the prompt's top edge. + ## [0.3.1] - 2026-07-15 - Fixed fullscreen Markdown table selections copying borders instead of tab-separated cell content ([ENG-4629](https://linear.app/primeintellect/issue/ENG-4629/respect-table-boundaries-when-selecting)). diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 84ccff8f3..52a8e5824 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -2463,6 +2463,7 @@ export class Editor implements Component, Focusable { this.autocompleteOverlay = this.tui.showOverlay(this.autocompleteOverlayComponent, { width: "100%", aboveMarker: this.autocompleteAnchorMarker, + offsetY: -1, nonCapturing: true, visible: () => this.focused && this.autocompleteState !== null, }); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2a09f372b..103edbb91 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -18,6 +18,7 @@ import { normalizeTerminalOutput, sliceByColumn, sliceWithWidth, + stripAnsi, visibleContentSpan, visibleWidth, } from "./utils.js"; @@ -1134,7 +1135,7 @@ export class TUI extends Container { col: number; w: number; scrollback: boolean; - aboveMarker?: { line: number; col: number }; + aboveMarker?: { line: number; col: number; offsetY: number }; }[] = []; let minLinesNeeded = result.length; @@ -1143,12 +1144,16 @@ export class TUI extends Container { for (const entry of visibleEntries) { const { component, options } = entry; const scrollback = options?.scrollback === true; - let aboveMarker: { line: number; col: number } | undefined; + let aboveMarker: { line: number; col: number; offsetY: number } | undefined; if (options?.aboveMarker) { for (let line = result.length - 1; line >= 0; line--) { const markerIndex = result[line].indexOf(options.aboveMarker); if (markerIndex === -1) continue; - aboveMarker = { line, col: visibleWidth(result[line].slice(0, markerIndex)) }; + aboveMarker = { + line, + col: visibleWidth(result[line].slice(0, markerIndex)), + offsetY: options.offsetY ?? 0, + }; result[line] = result[line].slice(0, markerIndex) + result[line].slice(markerIndex + options.aboveMarker.length); break; @@ -1194,8 +1199,14 @@ export class TUI extends Container { const { component, w, scrollback, aboveMarker } = renderedOverlay; let { overlayLines, row, col } = renderedOverlay; if (aboveMarker) { - const markerRow = aboveMarker.line - viewportStart; - if (markerRow <= 0 || markerRow >= termHeight) continue; + const markerRow = Math.max(1, aboveMarker.line - viewportStart + aboveMarker.offsetY); + if (markerRow >= termHeight) continue; + while (overlayLines.length > markerRow && stripAnsi(overlayLines[0] ?? "").trim().length === 0) { + overlayLines = overlayLines.slice(1); + } + while (overlayLines.length > markerRow && stripAnsi(overlayLines.at(-1) ?? "").trim().length === 0) { + overlayLines = overlayLines.slice(0, -1); + } if (overlayLines.length > markerRow) { overlayLines = overlayLines.slice(overlayLines.length - markerRow); } diff --git a/packages/tui/test/editor-autocomplete-overlay.test.ts b/packages/tui/test/editor-autocomplete-overlay.test.ts index 81d117e73..663eb5572 100644 --- a/packages/tui/test/editor-autocomplete-overlay.test.ts +++ b/packages/tui/test/editor-autocomplete-overlay.test.ts @@ -72,8 +72,30 @@ describe("editor autocomplete overlay", () => { await terminal.waitForRender(); const viewport = terminal.getViewport(); - assert.ok(viewport.some((line) => line.includes("help"))); + assert.ok(viewport[0]?.includes("help")); + assert.ok(viewport[1]?.includes("hotkeys")); assert.ok(viewport[3]?.includes("/")); tui.stop(); }); + + it("keeps one suggestion visible when the editor is at the terminal top edge", async () => { + const terminal = new VirtualTerminal(40, 3); + const tui = new TUI(terminal); + const editor = new Editor(tui, defaultEditorTheme); + tui.addChild(editor); + tui.setFocus(editor); + editor.setAutocompleteProvider(autocompleteProvider); + tui.start(); + + editor.handleInput("/"); + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + tui.requestRender(true); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("hotkeys")); + assert.ok(viewport[1]?.includes("/")); + tui.stop(); + }); }); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 1af736c98..2251eb89e 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2055,6 +2055,36 @@ describe("Editor component", () => { assert.equal(tui.hasOverlay(), false); }); + it("keeps the prompt top edge visible below autocomplete", async () => { + const terminal = new VirtualTerminal(60, 12); + const tui = new TUI(terminal); + const editor = new Editor(tui, defaultEditorTheme); + tui.addChild(editor); + tui.start(); + tui.enterFullscreen({ scroll: [], dock: editor, mouse: false }); + tui.setFocus(editor); + editor.setAutocompleteProvider({ + getSuggestions: async () => ({ + items: [ + { value: "/model", label: "model", description: "Change model" }, + { value: "/help", label: "help", description: "Show help" }, + ], + prefix: "/", + }), + applyCompletion, + }); + + editor.handleInput("/"); + await flushAutocomplete(); + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.match(viewport[9] ?? "", /^─+$/); + tui.stop(); + }); + it("auto-applies single force-file suggestion without showing menu", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); diff --git a/packages/tui/test/overlay-options.test.ts b/packages/tui/test/overlay-options.test.ts index 97e52f077..1199780f0 100644 --- a/packages/tui/test/overlay-options.test.ts +++ b/packages/tui/test/overlay-options.test.ts @@ -535,6 +535,39 @@ describe("TUI overlay options", () => { tui.stop(); }); + it("applies vertical offsets to marker-anchored overlays", async () => { + const terminal = new VirtualTerminal(20, 8); + const tui = new TUI(terminal); + const marker = "\x1b_pi:offset-overlay\x07"; + const content = new StaticOverlay([ + "transcript 1", + "transcript 2", + "transcript 3", + "underlay 1", + "prompt edge", + `${marker}input`, + "footer 1", + "footer 2", + ]); + + tui.addChild(content); + tui.showOverlay(new StaticOverlay(["suggestion", "description"]), { + width: "100%", + aboveMarker: marker, + offsetY: -1, + nonCapturing: true, + }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.equal(viewport[2]?.trimEnd(), "suggestion"); + assert.equal(viewport[3]?.trimEnd(), "description"); + assert.equal(viewport[4], "prompt edge"); + assert.equal(viewport[5], "input"); + tui.stop(); + }); + it("anchors above fullscreen dock content", async () => { const terminal = new VirtualTerminal(20, 8); const tui = new TUI(terminal); -- 2.54.0