diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6dd8230307a5..c208d4346fac 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -337,6 +337,8 @@ function deriveWorkLogEntries( if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + // Composer prompt suggestions have no mobile surface yet. + if (activity.kind === "prompt-suggestion") continue; if (activity.summary === "Checkpoint captured") continue; if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe9..a6ca5b55fe7f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2918,6 +2918,16 @@ describe("ProviderRuntimeIngestion", () => { }, }); + harness.emit({ + type: "turn.prompt-suggestion", + eventId: asEventId("evt-turn-prompt-suggestion"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), + payload: { suggestion: "Run the tests again" }, + }); + harness.emit({ type: "turn.diff.updated", eventId: asEventId("evt-turn-diff-updated"), @@ -2944,6 +2954,9 @@ describe("ProviderRuntimeIngestion", () => { entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "runtime.warning", ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "prompt-suggestion", + ) && entry.checkpoints.some( (checkpoint: ProviderRuntimeTestCheckpoint) => checkpoint.turnId === "turn-p1", ), @@ -2961,6 +2974,13 @@ describe("ProviderRuntimeIngestion", () => { expect(planActivity?.kind).toBe("turn.plan.updated"); expect(Array.isArray(planPayload?.plan)).toBe(true); + const suggestionActivity = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-prompt-suggestion", + ); + expect(suggestionActivity?.kind).toBe("prompt-suggestion"); + expect(suggestionActivity?.turnId).toBe("turn-p1"); + expect(suggestionActivity?.payload).toEqual({ suggestion: "Run the tests again" }); + const toolUpdate = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-item-updated", ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e64243..1bd59ef17467 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -508,6 +508,23 @@ export function runtimeEventToActivities( ]; } + case "turn.prompt-suggestion": { + // Predicted next user prompt for the turn that just completed. Clients + // read the newest row for the latest turn; the work log hides it. + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "prompt-suggestion", + summary: "Prompt suggestion", + payload: { suggestion: event.payload.suggestion }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "user-input.requested": { return [ { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 711a3c0df8f9..8c3e401200a3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -349,6 +349,7 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + assert.equal(createInput?.options.promptSuggestions, true); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -847,6 +848,132 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("emits turn.prompt-suggestion for the turn that just completed", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.prompt-suggestion", + ).pipe(Stream.runCollect, Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + // The SDK delivers the suggestion after `result`, once the turn is closed. + harness.query.emit({ + type: "prompt_suggestion", + suggestion: " Now run the tests ", + session_id: "sdk-session-1", + uuid: "ps-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed"); + const suggestion = runtimeEvents.at(-1); + assert.notEqual(completedIndex, -1); + assert.equal(suggestion?.type, "turn.prompt-suggestion"); + if (suggestion?.type === "turn.prompt-suggestion") { + assert.equal(suggestion.payload.suggestion, "Now run the tests"); + assert.equal(String(suggestion.turnId), String(turn.turnId)); + } + assert.isBelow(completedIndex, runtimeEvents.length - 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "keeps a trailing suggestion on its own turn when the next turn already started", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + const firstTurnCompleted = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runDrain, Effect.forkChild); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + yield* Fiber.join(firstTurnCompleted); + + // The user sends again before the SDK delivers the first turn's + // suggestion; it must not be re-homed onto the new turn. + const secondTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "and again", + attachments: [], + }); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.prompt-suggestion", + ).pipe(Stream.runCollect, Effect.forkChild); + harness.query.emit({ + type: "prompt_suggestion", + suggestion: "Now run the tests", + session_id: "sdk-session-1", + uuid: "ps-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const suggestion = runtimeEvents.at(-1); + assert.equal(suggestion?.type, "turn.prompt-suggestion"); + if (suggestion?.type === "turn.prompt-suggestion") { + assert.equal(String(suggestion.turnId), String(firstTurn.turnId)); + assert.notEqual(String(suggestion.turnId), String(secondTurn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2546,6 +2673,11 @@ describe("ClaudeAdapterLive", () => { yield* Effect.yieldNow; const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + // A suggestion with no turn to attach to is dropped, not surfaced. + assert.equal( + runtimeEvents.some((event) => event.type === "turn.prompt-suggestion"), + false, + ); // Exactly one warning: the high-priority notification. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1fb7e123260a..7737d35efaec 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -309,6 +309,12 @@ interface ClaudeSessionContext { /** Task ids that have started and not yet reached a terminal state. */ readonly liveTaskIds: Set; turnState: ClaudeTurnState | undefined; + /** + * Turn id of the most recently completed turn. `prompt_suggestion` arrives + * after `result` (turnState is already cleared), so the suggestion is + * attributed to this turn. + */ + lastCompletedTurnId: TurnId | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastKnownTotalProcessedTokens: number | undefined; @@ -2418,6 +2424,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); const updatedAt = yield* nowIso; + context.lastCompletedTurnId = turnState.turnId; context.turnState = undefined; context.session = { ...context.session, @@ -2429,6 +2436,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* updateResumeCursor(context); }); + const handlePromptSuggestion = Effect.fn("handlePromptSuggestion")(function* ( + context: ClaudeSessionContext, + message: Extract, + ) { + const suggestion = message.suggestion.trim(); + if (suggestion.length === 0) { + return; + } + // The SDK emits the suggestion after `result`, i.e. after turn.completed + // cleared turnState. Attribute it to the turn that just finished, never to + // a turn the user may already have started meanwhile: that turn's own + // suggestion (if any) follows its own result. + const turnId = context.lastCompletedTurnId; + if (!turnId) { + return; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.prompt-suggestion", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: { suggestion }, + providerRefs: nativeProviderRefs(context), + raw: { + source: "claude.sdk.message" as const, + method: "claude/prompt_suggestion", + payload: message, + }, + }); + }); + const handleStreamEvent = Effect.fn("handleStreamEvent")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -3609,8 +3650,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "rate_limit_event": yield* handleSdkTelemetryMessage(context, message); return; - // Composer prompt suggestions have no T3 surface; consumed deliberately. case "prompt_suggestion": + yield* handlePromptSuggestion(context, message); return; default: { // Exhaustiveness guard (see handleSystemMessage): new SDK top-level @@ -4327,6 +4368,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, + // Opt in to the SDK's predicted next prompt (one `prompt_suggestion` + // after each turn's `result`). The user's own Claude settings still + // apply: `promptSuggestionEnabled: false` in settings.json or + // CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false suppresses them. + promptSuggestions: true, canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], @@ -4429,6 +4475,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turnState: undefined, lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, + lastCompletedTurnId: undefined, lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..bb93929d9df4 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -69,6 +69,7 @@ import { type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn, isMacPlatform } from "~/lib/utils"; +import { Kbd } from "~/components/ui/kbd"; import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, @@ -884,6 +885,11 @@ interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + /** + * Provider-predicted next prompt. Rendered as ghost text in place of the + * placeholder while the editor is empty; the parent accepts it on Tab. + */ + promptSuggestion?: string | null; className?: string; onRemoveTerminalContext: (contextId: string) => void; onChange: ( @@ -1533,6 +1539,7 @@ function ComposerPromptEditorInner({ skills, disabled, placeholder, + promptSuggestion, className, onRemoveTerminalContext, onChange, @@ -1758,13 +1765,25 @@ function ComposerPromptEditorInner({ className, )} data-testid="composer-editor" - aria-placeholder={placeholder} + aria-placeholder={ + promptSuggestion + ? `Suggested: ${promptSuggestion}. Press Tab to accept.` + : placeholder + } placeholder={} onPaste={onPaste} /> } placeholder={ - terminalContexts.length > 0 ? null : ( + terminalContexts.length > 0 ? null : promptSuggestion ? ( +
+ {promptSuggestion} + Tab +
+ ) : (
{placeholder}
@@ -1794,6 +1813,7 @@ export function ComposerPromptEditor({ skills, disabled, placeholder, + promptSuggestion, className, onRemoveTerminalContext, onChange, @@ -1838,6 +1858,7 @@ export function ComposerPromptEditor({ editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} + {...(promptSuggestion ? { promptSuggestion } : {})} /> ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d0ca8a765ba..1b8bce689591 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -267,6 +267,7 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import type { ContextWindowSnapshot } from "../../lib/contextWindow"; +import { derivePromptSuggestion } from "../../lib/promptSuggestion"; import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, @@ -1251,6 +1252,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const isComposerApprovalState = activePendingApproval !== null; + + // ------------------------------------------------------------------ + // Prompt suggestion (provider-predicted next prompt, Tab to accept) + // ------------------------------------------------------------------ + const promptSuggestion = useMemo( + () => (activeThread ? derivePromptSuggestion(activeThread) : null), + [activeThread], + ); + // Ghost text only while the composer is idle and empty: any other composer + // state (approval, question, plan follow-up, draft text) owns the input. + // Touch viewports have no Tab key and nothing to tap, so they keep the + // informative placeholder (same gate as Enter-to-send). + const showPromptSuggestion = + !isMobileViewport && + promptSuggestion !== null && + prompt.length === 0 && + composerTerminalContexts.length === 0 && + !isComposerApprovalState && + activePendingProgress === null && + pendingUserInputs.length === 0 && + !(showPlanFollowUpPrompt && activeProposedPlan) && + !isSendBusy && + !isConnecting && + phase !== "disconnected"; const activePendingUserInput = pendingUserInputs[0] ?? null; const showComposerTopDrawer = isComposerApprovalState || @@ -2100,6 +2125,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } + // Plain Tab only: Shift+Tab keeps its reverse-focus meaning when plan + // mode is off (the plan toggle above returns false in that case). + if (key === "Tab" && !event.shiftKey && showPromptSuggestion && promptSuggestion !== null) { + setPromptFromTraits(promptSuggestion); + return true; + } const submissionIntent = key === "Enter" ? composerSubmissionIntentForEnter({ @@ -3405,6 +3436,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} onPaste={onComposerPaste} + promptSuggestion={showPromptSuggestion ? promptSuggestion : null} placeholder={ isComposerApprovalState ? (activePendingApproval?.detail ?? diff --git a/apps/web/src/lib/promptSuggestion.test.ts b/apps/web/src/lib/promptSuggestion.test.ts new file mode 100644 index 000000000000..8f0c31904f73 --- /dev/null +++ b/apps/web/src/lib/promptSuggestion.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + EventId, + MessageId, + type OrchestrationLatestTurn, + type OrchestrationMessage, + type OrchestrationThreadActivity, + TurnId, +} from "@t3tools/contracts"; + +import { derivePromptSuggestion, type PromptSuggestionSource } from "./promptSuggestion"; + +function makeActivity( + id: string, + kind: string, + payload: unknown, + overrides: Partial> = {}, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "info", + kind, + summary: kind, + payload, + turnId: TurnId.make("turn-1"), + createdAt: "2026-03-23T00:00:10.000Z", + ...overrides, + }; +} + +function makeUserMessage(id: string, createdAt: string): OrchestrationMessage { + return { + id: MessageId.make(id), + role: "user", + text: "hello", + turnId: TurnId.make("turn-1"), + streaming: false, + createdAt, + updatedAt: createdAt, + }; +} + +function makeLatestTurn(overrides: Partial = {}): OrchestrationLatestTurn { + return { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-03-23T00:00:00.000Z", + startedAt: "2026-03-23T00:00:00.000Z", + completedAt: "2026-03-23T00:00:09.000Z", + assistantMessageId: null, + ...overrides, + }; +} + +function makeThread(overrides: Partial = {}): PromptSuggestionSource { + return { + activities: [ + makeActivity("activity-1", "tool.completed", {}), + makeActivity("activity-2", "prompt-suggestion", { suggestion: " Run the tests " }), + ], + messages: [makeUserMessage("message-1", "2026-03-23T00:00:00.000Z")], + latestTurn: makeLatestTurn(), + session: { status: "ready" }, + ...overrides, + }; +} + +describe("derivePromptSuggestion", () => { + it("returns the trimmed suggestion for the completed latest turn", () => { + expect(derivePromptSuggestion(makeThread())).toBe("Run the tests"); + }); + + it("uses the newest suggestion row", () => { + expect( + derivePromptSuggestion( + makeThread({ + activities: [ + makeActivity("activity-1", "prompt-suggestion", { suggestion: "older" }), + makeActivity("activity-2", "prompt-suggestion", { suggestion: "newer" }), + ], + }), + ), + ).toBe("newer"); + }); + + it("returns null without a suggestion row", () => { + expect( + derivePromptSuggestion( + makeThread({ activities: [makeActivity("activity-1", "tool.completed", {})] }), + ), + ).toBeNull(); + }); + + it("returns null when the suggestion belongs to an earlier turn", () => { + expect( + derivePromptSuggestion( + makeThread({ latestTurn: makeLatestTurn({ turnId: TurnId.make("turn-2") }) }), + ), + ).toBeNull(); + }); + + it("returns null while the latest turn is still running", () => { + expect( + derivePromptSuggestion(makeThread({ latestTurn: makeLatestTurn({ state: "running" }) })), + ).toBeNull(); + expect(derivePromptSuggestion(makeThread({ session: { status: "running" } }))).toBeNull(); + expect(derivePromptSuggestion(makeThread({ session: { status: "starting" } }))).toBeNull(); + }); + + it("returns null once a newer user message exists", () => { + expect( + derivePromptSuggestion( + makeThread({ + messages: [ + makeUserMessage("message-1", "2026-03-23T00:00:00.000Z"), + makeUserMessage("message-2", "2026-03-23T00:00:11.000Z"), + ], + }), + ), + ).toBeNull(); + }); + + it("returns null for a malformed or blank payload", () => { + expect( + derivePromptSuggestion( + makeThread({ activities: [makeActivity("activity-1", "prompt-suggestion", {})] }), + ), + ).toBeNull(); + expect( + derivePromptSuggestion( + makeThread({ + activities: [makeActivity("activity-1", "prompt-suggestion", { suggestion: " " })], + }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/promptSuggestion.ts b/apps/web/src/lib/promptSuggestion.ts new file mode 100644 index 000000000000..6248f4292998 --- /dev/null +++ b/apps/web/src/lib/promptSuggestion.ts @@ -0,0 +1,68 @@ +import type { + OrchestrationLatestTurn, + OrchestrationMessage, + OrchestrationSessionStatus, + OrchestrationThreadActivity, +} from "@t3tools/contracts"; + +/** Activity kind the server appends for a provider's predicted next prompt. */ +export const PROMPT_SUGGESTION_ACTIVITY_KIND = "prompt-suggestion"; + +export interface PromptSuggestionSource { + readonly activities: ReadonlyArray; + readonly messages: ReadonlyArray; + readonly latestTurn: OrchestrationLatestTurn | null; + readonly session: { readonly status: OrchestrationSessionStatus } | null; +} + +function suggestionFromActivity(activity: OrchestrationThreadActivity): string | null { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const suggestion = payload?.suggestion; + if (typeof suggestion !== "string") { + return null; + } + const trimmed = suggestion.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * The provider's predicted next prompt for the thread's latest turn, or null + * when there is none or it is stale. + * + * A suggestion only makes sense at the moment the turn it was generated for + * has settled and nothing newer has happened: the latest turn must be the + * suggestion's turn and completed, the session must not be running, and no + * user message may have been sent after the suggestion arrived. + */ +export function derivePromptSuggestion(thread: PromptSuggestionSource): string | null { + const latestTurn = thread.latestTurn; + if (!latestTurn || latestTurn.state !== "completed") { + return null; + } + const sessionStatus = thread.session?.status; + if (sessionStatus === "running" || sessionStatus === "starting") { + return null; + } + + for (let index = thread.activities.length - 1; index >= 0; index -= 1) { + const activity = thread.activities[index]; + if (!activity || activity.kind !== PROMPT_SUGGESTION_ACTIVITY_KIND) { + continue; + } + if (activity.turnId !== latestTurn.turnId) { + return null; + } + const suggestion = suggestionFromActivity(activity); + if (suggestion === null) { + return null; + } + const supersededByUserMessage = thread.messages.some( + (message) => message.role === "user" && message.createdAt > activity.createdAt, + ); + return supersededByUserMessage ? null : suggestion; + } + return null; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 05be83a20ca9..44ec1137e3fe 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1910,6 +1910,28 @@ describe("deriveWorkLogEntries context window handling", () => { expect(entries[0]?.label).toBe("Ran command"); }); + it("excludes prompt suggestions from the work log", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "suggestion-1", + turnId: "turn-1", + kind: "prompt-suggestion", + summary: "Prompt suggestion", + tone: "info", + }), + makeActivity({ + id: "tool-1", + turnId: "turn-1", + kind: "tool.completed", + summary: "Ran command", + tone: "tool", + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]?.label).toBe("Ran command"); + }); + it("keeps context compaction activities as normal work log entries", () => { const entries = deriveWorkLogEntries([ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4b35a6e24cc0..d802b4997f3a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -880,6 +880,7 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + if (activity.kind === "prompt-suggestion") continue; if (activity.summary === "Checkpoint captured") continue; if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; diff --git a/docs/user/composer.md b/docs/user/composer.md index 35d634556d88..0f39e78adbf4 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -10,6 +10,17 @@ becomes available after every upload finishes. Failed uploads can be retried or On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into the composer or paste them into a message. +## Suggested next prompt + +After a Claude turn finishes, T3 Code can show Claude's predicted next prompt as faded text in the +empty composer. Press `Tab` to accept it into the composer, then edit or send it as usual. Start +typing to ignore it. The suggestion disappears once you send a message or a new turn starts. + +Suggestions come from Claude Code itself and follow your Claude settings: turning off **Prompt +suggestions** in Claude Code (`promptSuggestionEnabled: false` in `settings.json`, or the +`CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false` environment variable) turns them off in T3 Code too. +Other providers do not offer suggestions yet. + ## Commands and skills Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index e813c6ce4a35..086189786657 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -165,6 +165,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "turn.completed", "turn.aborted", "turn.plan.updated", + "turn.prompt-suggestion", "turn.proposed.delta", "turn.proposed.completed", "turn.diff.updated", @@ -216,6 +217,7 @@ const TurnStartedType = Schema.Literal("turn.started"); const TurnCompletedType = Schema.Literal("turn.completed"); const TurnAbortedType = Schema.Literal("turn.aborted"); const TurnPlanUpdatedType = Schema.Literal("turn.plan.updated"); +const TurnPromptSuggestionType = Schema.Literal("turn.prompt-suggestion"); const TurnProposedDeltaType = Schema.Literal("turn.proposed.delta"); const TurnProposedCompletedType = Schema.Literal("turn.proposed.completed"); const TurnDiffUpdatedType = Schema.Literal("turn.diff.updated"); @@ -391,6 +393,15 @@ const TurnPlanUpdatedPayload = Schema.Struct({ }); export type TurnPlanUpdatedPayload = typeof TurnPlanUpdatedPayload.Type; +/** + * Provider-predicted next user prompt for the turn that just completed. + * Emitted at most once per turn, after `turn.completed`. + */ +const TurnPromptSuggestionPayload = Schema.Struct({ + suggestion: TrimmedNonEmptyStringSchema, +}); +export type TurnPromptSuggestionPayload = typeof TurnPromptSuggestionPayload.Type; + const TurnProposedDeltaPayload = Schema.Struct({ delta: Schema.String, }); @@ -910,6 +921,14 @@ const ProviderRuntimeTurnPlanUpdatedEvent = Schema.Struct({ }); export type ProviderRuntimeTurnPlanUpdatedEvent = typeof ProviderRuntimeTurnPlanUpdatedEvent.Type; +const ProviderRuntimeTurnPromptSuggestionEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnPromptSuggestionType, + payload: TurnPromptSuggestionPayload, +}); +export type ProviderRuntimeTurnPromptSuggestionEvent = + typeof ProviderRuntimeTurnPromptSuggestionEvent.Type; + const ProviderRuntimeTurnProposedDeltaEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: TurnProposedDeltaType, @@ -1159,6 +1178,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeTurnCompletedEvent, ProviderRuntimeTurnAbortedEvent, ProviderRuntimeTurnPlanUpdatedEvent, + ProviderRuntimeTurnPromptSuggestionEvent, ProviderRuntimeTurnProposedDeltaEvent, ProviderRuntimeTurnProposedCompletedEvent, ProviderRuntimeTurnDiffUpdatedEvent,