diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6..22b3dbf62ab0 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -47,7 +47,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; -import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { + ProviderRuntimeIngestionLive, + runtimeEventToActivities, +} from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -66,6 +69,35 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +describe("runtimeEventToActivities", () => { + it("persists prompt suggestions as hidden turn-scoped composer metadata", () => { + const activities = runtimeEventToActivities({ + type: "thread.metadata.updated", + eventId: asEventId("evt-prompt-suggestion"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: "2026-08-11T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { suggestedPrompt: "run the tests" }, + }); + + expect(activities).toEqual([ + { + id: asEventId("evt-prompt-suggestion"), + createdAt: "2026-08-11T00:00:00.000Z", + tone: "info", + kind: "prompt-suggestion.updated", + summary: "Prompt suggestion updated", + payload: { + suggestedPrompt: "run the tests", + timelineBypass: true, + }, + turnId: asTurnId("turn-1"), + }, + ]); + }); +}); + type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..7301e53f8de8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -368,6 +368,28 @@ export function runtimeEventToActivities( : {}; })(); switch (event.type) { + case "thread.metadata.updated": { + if (!event.payload.suggestedPrompt) { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "prompt-suggestion.updated", + summary: "Prompt suggestion updated", + payload: { + suggestedPrompt: event.payload.suggestedPrompt, + // Composer state only; never render this metadata in the work log. + timelineBypass: true, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "request.opened": { if (event.payload.requestType === "tool_user_input") { return []; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 711b0f6f6aa3..4f39cb34569a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -355,6 +355,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), @@ -380,6 +381,24 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("disables prompt suggestions from Claude provider settings", () => { + const harness = makeHarness({ claudeConfig: { promptSuggestions: false } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.promptSuggestions, false); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -978,6 +997,59 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("emits prompt suggestions for the completed turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(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: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-suggestion", + uuid: "result-suggestion", + } as unknown as SDKMessage); + harness.query.emit({ + type: "prompt_suggestion", + suggestion: " run the tests ", + session_id: "sdk-session-suggestion", + uuid: "prompt-suggestion", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const suggestionEvent = runtimeEvents.find( + (event) => + event.type === "thread.metadata.updated" && event.payload.suggestedPrompt !== undefined, + ); + assert.equal(suggestionEvent?.type, "thread.metadata.updated"); + if (suggestionEvent?.type === "thread.metadata.updated") { + assert.equal(String(suggestionEvent.turnId), String(turn.turnId)); + assert.equal(suggestionEvent.payload.suggestedPrompt, "run the tests"); + } + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("does not emit turn.completed for a result with no active turn", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2157,9 +2229,10 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - // Undeclared wire-only roster snapshot + every typed UX-internal - // subtype and top-level type consumed silently: none may surface as - // unknown-subtype warnings. + // Undeclared wire-only roster snapshot + typed UX-internal messages + // must not surface as unknown-subtype warnings. A prompt suggestion + // before the first turn is intentionally ignored because it has no + // turn to attach to. for (const message of [ { type: "system", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b1fa74e81bda..7f8ae8b7ad39 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3512,9 +3512,30 @@ 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": + case "prompt_suggestion": { + const suggestion = message.suggestion.trim(); + const turnId = context.turnState?.turnId ?? context.turns.at(-1)?.id; + if (!suggestion || !turnId) { + return; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.metadata.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: { suggestedPrompt: suggestion }, + providerRefs: nativeProviderRefs(context), + raw: { + source: "claude.sdk.message", + method: "claude/prompt_suggestion", + payload: message, + }, + }); return; + } default: { // Exhaustiveness guard (see handleSystemMessage): new SDK top-level // message types fail typecheck here instead of warning at runtime. @@ -4119,6 +4140,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, + promptSuggestions: claudeSettings.promptSuggestions, canUseTool, env: claudeEnvironment, additionalDirectories, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dcc3ac0b5db7..9c4933609d23 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + promptSuggestions: true, ...overrides, }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d38a3064910d..6c2b85cccc69 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: ["claude-custom"], launchArgs: "", + promptSuggestions: true, }); assert.deepEqual( next.textGenerationModelSelection, @@ -522,6 +523,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: [], launchArgs: "", + promptSuggestions: true, }); assert.deepEqual(next.providers.opencode, { enabled: true, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 38490aeaa7e4..6b0ea174a60d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -39,6 +39,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldAcceptPromptSuggestionOnTab, shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; @@ -226,6 +227,7 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; +import { deriveLatestPromptSuggestion } from "../../lib/promptSuggestion"; const runtimeModeConfig: Record< RuntimeMode, @@ -928,6 +930,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return formatProviderDisplayName(activeThreadModelSelection.instanceId); }, [providerStatuses, activeThreadModelSelection]); + const latestPromptSuggestion = useMemo( + () => + selectedProvider === "claudeAgent" && phase === "ready" + ? deriveLatestPromptSuggestion(activeThreadActivities ?? [], activeThread?.latestTurn) + : null, + [activeThread?.latestTurn, activeThreadActivities, phase, selectedProvider], + ); // ------------------------------------------------------------------ // Composer-local state @@ -1891,6 +1900,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } + if ( + shouldAcceptPromptSuggestionOnTab({ + key, + shiftKey: event.shiftKey, + prompt, + suggestedPrompt: latestPromptSuggestion, + }) + ) { + return applyPromptReplacement(0, 0, latestPromptSuggestion ?? ""); + } if ( key === "Enter" && shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) @@ -3051,7 +3070,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Enable a provider in Settings to send a message" : phase === "disconnected" ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : (latestPromptSuggestion ?? + "Ask anything, @tag files/folders, $use skills, or / for commands") } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..58c6fdb0627d 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -37,6 +37,21 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("shows Claude prompt suggestions as an on-by-default switch", () => { + const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; + expect(claude).toBeDefined(); + + const promptSuggestions = deriveProviderSettingsFields(claude!).find( + (field) => field.key === "promptSuggestions", + ); + + expect(promptSuggestions).toMatchObject({ + label: "Prompt suggestions", + control: "switch", + defaultBooleanValue: true, + }); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b8ef7443611a..30755ee67bff 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -8,6 +8,7 @@ import { isCollapsedCursorAdjacentToInlineToken, parseStandaloneComposerSlashCommand, replaceTextRange, + shouldAcceptPromptSuggestionOnTab, shouldSubmitComposerOnEnter, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; @@ -26,6 +27,38 @@ describe("shouldSubmitComposerOnEnter", () => { }); }); +describe("shouldAcceptPromptSuggestionOnTab", () => { + it("accepts a suggestion with plain Tab in an empty composer", () => { + expect( + shouldAcceptPromptSuggestionOnTab({ + key: "Tab", + shiftKey: false, + prompt: "", + suggestedPrompt: "run the tests", + }), + ).toBe(true); + }); + + it("does not replace user input or handle Shift+Tab", () => { + expect( + shouldAcceptPromptSuggestionOnTab({ + key: "Tab", + shiftKey: false, + prompt: "already typing", + suggestedPrompt: "run the tests", + }), + ).toBe(false); + expect( + shouldAcceptPromptSuggestionOnTab({ + key: "Tab", + shiftKey: true, + prompt: "", + suggestedPrompt: "run the tests", + }), + ).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2d1d3aed3b1e..4b93b6be89f0 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -18,6 +18,20 @@ export function shouldSubmitComposerOnEnter(input: { return !input.isMobileViewport && !input.shiftKey; } +export function shouldAcceptPromptSuggestionOnTab(input: { + key: string; + shiftKey: boolean; + prompt: string; + suggestedPrompt: string | null; +}): boolean { + return ( + input.key === "Tab" && + !input.shiftKey && + input.prompt.length === 0 && + input.suggestedPrompt !== null + ); +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/lib/promptSuggestion.test.ts b/apps/web/src/lib/promptSuggestion.test.ts new file mode 100644 index 000000000000..531f45be219d --- /dev/null +++ b/apps/web/src/lib/promptSuggestion.test.ts @@ -0,0 +1,44 @@ +import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveLatestPromptSuggestion } from "./promptSuggestion"; + +function suggestionActivity(turnId: string, suggestedPrompt: unknown): OrchestrationThreadActivity { + return { + id: EventId.make(`suggestion-${turnId}`), + tone: "info", + kind: "prompt-suggestion.updated", + summary: "Prompt suggestion updated", + payload: { suggestedPrompt }, + turnId: TurnId.make(turnId), + createdAt: "2026-08-11T00:00:00.000Z", + }; +} + +describe("deriveLatestPromptSuggestion", () => { + it("returns the suggestion for the latest completed turn", () => { + expect( + deriveLatestPromptSuggestion([suggestionActivity("turn-1", " run the tests ")], { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-11T00:00:00.000Z", + startedAt: "2026-08-11T00:00:00.000Z", + completedAt: "2026-08-11T00:00:01.000Z", + assistantMessageId: null, + }), + ).toBe("run the tests"); + }); + + it("hides stale suggestions while a newer turn runs", () => { + expect( + deriveLatestPromptSuggestion([suggestionActivity("turn-1", "run the tests")], { + turnId: TurnId.make("turn-2"), + state: "running", + requestedAt: "2026-08-11T00:00:02.000Z", + startedAt: "2026-08-11T00:00:02.000Z", + completedAt: null, + assistantMessageId: null, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/promptSuggestion.ts b/apps/web/src/lib/promptSuggestion.ts new file mode 100644 index 000000000000..9b409819cb16 --- /dev/null +++ b/apps/web/src/lib/promptSuggestion.ts @@ -0,0 +1,31 @@ +import type { OrchestrationLatestTurn, OrchestrationThreadActivity } from "@t3tools/contracts"; + +export function deriveLatestPromptSuggestion( + activities: ReadonlyArray, + latestTurn: OrchestrationLatestTurn | null | undefined, +): string | null { + if (!latestTurn || latestTurn.state !== "completed") { + return null; + } + + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if ( + !activity || + activity.kind !== "prompt-suggestion.updated" || + activity.turnId !== latestTurn.turnId + ) { + continue; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const suggestion = payload?.suggestedPrompt; + if (typeof suggestion === "string" && suggestion.trim().length > 0) { + return suggestion.trim(); + } + } + + return null; +} diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 79f1211cf40d..12bf1d5d77fe 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -1,5 +1,9 @@ # Claude +After Claude finishes a turn, it may suggest a likely follow-up in the empty message composer. +Press Tab to copy that suggestion into the composer; T3 Code does not send it until you submit it. +Prompt suggestions are on by default and can be changed for each Claude provider in Settings. + This guide is for people who want to use more than one Claude setup in T3 Code. For Codex, see [Codex](./providers-codex.md). For first-time setup, see [Install T3 Code](./install.md). diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 7563ac155681..34f781d928bf 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -49,6 +49,26 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.payload.plan[1]?.status).toBe("inProgress"); }); + it("decodes provider prompt suggestions as thread metadata", () => { + const parsed = decodeRuntimeEvent({ + type: "thread.metadata.updated", + eventId: "event-suggestion-1", + provider: "claudeAgent", + createdAt: "2026-08-11T00:00:00.000Z", + threadId: "thread-1", + turnId: "turn-1", + payload: { + suggestedPrompt: "run the tests", + }, + }); + + expect(parsed.type).toBe("thread.metadata.updated"); + if (parsed.type !== "thread.metadata.updated") { + throw new Error("expected thread.metadata.updated"); + } + expect(parsed.payload.suggestedPrompt).toBe("run the tests"); + }); + it("decodes proposed-plan completion events", () => { const parsed = decodeRuntimeEvent({ type: "turn.proposed.completed", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..6b11e8dbca8a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -302,6 +302,7 @@ export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; const ThreadMetadataUpdatedPayload = Schema.Struct({ name: Schema.optional(TrimmedNonEmptyStringSchema), + suggestedPrompt: Schema.optional(TrimmedNonEmptyStringSchema), metadata: Schema.optional(UnknownRecordSchema), }); export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..3a278fa10832 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -355,9 +355,18 @@ export const ClaudeSettings = makeProviderSettingsSchema( }, }), ), + promptSuggestions: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Prompt suggestions", + description: + "Let Claude suggest a likely follow-up after each turn. Press Tab in the composer to use it.", + providerSettingsForm: { control: "switch" }, + }), + ), }, { - order: ["binaryPath", "homePath", "launchArgs"], + order: ["binaryPath", "homePath", "launchArgs", "promptSuggestions"], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -681,6 +690,7 @@ const ClaudeSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), launchArgs: Schema.optionalKey(TrimmedString), + promptSuggestions: Schema.optionalKey(Schema.Boolean), }); const CursorSettingsPatch = Schema.Struct({