Unified · Split
| @@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => { · 412 unmodified lines | ||
| 413 | 413 | ); |
| 414 | 414 | }); |
| 415 | 415 | |
| 416 | + it.effect("passes the configured auto-compaction window to Claude", () => { | |
| 417 | + const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } }); | |
| 418 | + return Effect.gen(function* () { | |
| 419 | + const adapter = yield* ClaudeAdapter; | |
| 420 | + yield* adapter.startSession({ | |
| 421 | + threadId: THREAD_ID, | |
| 422 | + provider: ProviderDriverKind.make("claudeAgent"), | |
| 423 | + runtimeMode: "full-access", | |
| 424 | + }); | |
| 425 | + | |
| 426 | + const options = harness.getLastCreateQueryInput()?.options; | |
| 427 | + assert.deepEqual(options?.settings, { autoCompactWindow: 300000 }); | |
| 428 | + assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]); | |
| 429 | + }).pipe( | |
| 430 | + Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 431 | + Effect.provide(harness.layer), | |
| 432 | + ); | |
| 433 | + }); | |
| 434 | + | |
| 416 | 435 | it.effect("forwards claude effort levels into query options", () => { |
| 417 | 436 | const harness = makeHarness(); |
| 418 | 437 | return Effect.gen(function* () { |
| @@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => { · 311 unmodified lines | ||
| 730 | 749 | ); |
| 731 | 750 | }); |
| 732 | 751 | |
| 752 | + it.effect("keeps compact commands intact when ultrathink is selected", () => { | |
| 753 | + const harness = makeHarness(); | |
| 754 | + return Effect.gen(function* () { | |
| 755 | + const adapter = yield* ClaudeAdapter; | |
| 756 | + const modelSelection = createModelSelection( | |
| 757 | + ProviderInstanceId.make("claudeAgent"), | |
| 758 | + "claude-sonnet-4-6", | |
| 759 | + [{ id: "effort", value: "ultrathink" }], | |
| 760 | + ); | |
| 761 | + const session = yield* adapter.startSession({ | |
| 762 | + threadId: THREAD_ID, | |
| 763 | + provider: ProviderDriverKind.make("claudeAgent"), | |
| 764 | + modelSelection, | |
| 765 | + runtimeMode: "full-access", | |
| 766 | + }); | |
| 767 | + | |
| 768 | + yield* adapter.sendTurn({ | |
| 769 | + threadId: session.threadId, | |
| 770 | + input: "/compact", | |
| 771 | + attachments: [], | |
| 772 | + modelSelection, | |
| 773 | + }); | |
| 774 | + | |
| 775 | + const promptText = yield* Effect.promise(() => | |
| 776 | + readFirstPromptText(harness.getLastCreateQueryInput()), | |
| 777 | + ); | |
| 778 | + assert.equal(promptText, "/compact"); | |
| 779 | + }).pipe( | |
| 780 | + Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 781 | + Effect.provide(harness.layer), | |
| 782 | + ); | |
| 783 | + }); | |
| 784 | + | |
| 733 | 785 | it.effect("embeds image attachments in Claude user messages", () => { |
| 734 | 786 | const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); |
| 735 | 787 | const harness = makeHarness({ |
| @@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => { · 3664 unmodified lines | ||
| 4400 | 4452 | ); |
| 4401 | 4453 | }); |
| 4402 | 4454 | |
| 4455 | + it.effect("routes Claude resume compaction through the shared user-input UI", () => { | |
| 4456 | + const harness = makeHarness(); | |
| 4457 | + return Effect.gen(function* () { | |
| 4458 | + const adapter = yield* ClaudeAdapter; | |
| 4459 | + const session = yield* adapter.startSession({ | |
| 4460 | + threadId: RESUME_THREAD_ID, | |
| 4461 | + provider: ProviderDriverKind.make("claudeAgent"), | |
| 4462 | + resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" }, | |
| 4463 | + runtimeMode: "full-access", | |
| 4464 | + }); | |
| 4465 | + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); | |
| 4466 | + | |
| 4467 | + const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog; | |
| 4468 | + assert.equal(typeof onUserDialog, "function"); | |
| 4469 | + if (!onUserDialog) return; | |
| 4470 | + | |
| 4471 | + const dialogPromise = onUserDialog( | |
| 4472 | + { | |
| 4473 | + dialogKind: "resume_return", | |
| 4474 | + payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, | |
| 4475 | + }, | |
| 4476 | + { signal: new AbortController().signal }, | |
| 4477 | + ); | |
| 4478 | + | |
| 4479 | + const requested = yield* Stream.runHead(adapter.streamEvents); | |
| 4480 | + assert.equal(requested._tag, "Some"); | |
| 4481 | + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return; | |
| 4482 | + const question = requested.value.payload.questions[0]; | |
| 4483 | + assert.equal(question?.header, "Resume session"); | |
| 4484 | + assert.match(question?.question ?? "", /2h 25m/); | |
| 4485 | + assert.match(question?.question ?? "", /275,123 tokens/); | |
| 4486 | + assert.deepEqual( | |
| 4487 | + question?.options.map((option) => option.label), | |
| 4488 | + ["Compact and continue", "Keep full history", "Don't ask again"], | |
| 4489 | + ); | |
| 4490 | + if (!question || !requested.value.requestId) return; | |
| 4491 | + | |
| 4492 | + yield* adapter.respondToUserInput( | |
| 4493 | + session.threadId, | |
| 4494 | + ApprovalRequestId.make(requested.value.requestId), | |
| 4495 | + { [question.id]: "Compact and continue" }, | |
| 4496 | + ); | |
| 4497 | + | |
| 4498 | + const resolved = yield* Stream.runHead(adapter.streamEvents); | |
| 4499 | + assert.equal(resolved._tag, "Some"); | |
| 4500 | + if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved"); | |
| 4501 | + assert.deepEqual(yield* Effect.promise(() => dialogPromise), { | |
| 4502 | + behavior: "completed", | |
| 4503 | + result: "compact", | |
| 4504 | + }); | |
| 4505 | + }).pipe( | |
| 4506 | + Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 4507 | + Effect.provide(harness.layer), | |
| 4508 | + ); | |
| 4509 | + }); | |
| 4510 | + | |
| 4403 | 4511 | it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { |
| 4404 | 4512 | const harness = makeHarness(); |
| 4405 | 4513 | return Effect.gen(function* () { |
| @@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => { · 283 unmodified lines | ||
| 4689 | 4797 | ); |
| 4690 | 4798 | }); |
| 4691 | 4799 | |
| 4800 | + it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => { | |
| 4801 | + const harness = makeHarness(); | |
| 4802 | + return Effect.gen(function* () { | |
| 4803 | + const adapter = yield* ClaudeAdapter; | |
| 4804 | + | |
| 4805 | + yield* adapter.startSession({ | |
| 4806 | + threadId: THREAD_ID, | |
| 4807 | + provider: ProviderDriverKind.make("claudeAgent"), | |
| 4808 | + runtimeMode: "approval-required", | |
| 4809 | + }); | |
| 4810 | + | |
| 4811 | + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); | |
| 4812 | + | |
| 4813 | + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; | |
| 4814 | + assert.equal(typeof canUseTool, "function"); | |
| 4815 | + if (!canUseTool) { | |
| 4816 | + return; | |
| 4817 | + } | |
| 4818 | + | |
| 4819 | + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( | |
| 4820 | + Stream.runCollect, | |
| 4821 | + Effect.forkChild, | |
| 4822 | + ); | |
| 4823 | + | |
| 4824 | + // Abort before the call so the adapter's listener registration can | |
| 4825 | + // never observe the abort event, only the recheck can. | |
| 4826 | + const controller = new AbortController(); | |
| 4827 | + controller.abort(); | |
| 4828 | + const permissionPromise = canUseTool( | |
| 4829 | + "AskUserQuestion", | |
| 4830 | + { | |
| 4831 | + questions: [ | |
| 4832 | + { | |
| 4833 | + question: "Continue?", | |
| 4834 | + header: "Continue", | |
| 4835 | + options: [{ label: "Yes", description: "Proceed" }], | |
| 4836 | + multiSelect: false, | |
| 4837 | + }, | |
| 4838 | + ], | |
| 4839 | + }, | |
| 4840 | + { | |
| 4841 | + signal: controller.signal, | |
| 4842 | + toolUseID: "tool-ask-pre-aborted", | |
| 4843 | + }, | |
| 4844 | + ); | |
| 4845 | + | |
| 4846 | + const permissionResult = yield* Effect.promise(() => permissionPromise); | |
| 4847 | + assert.deepEqual(permissionResult, { | |
| 4848 | + behavior: "deny", | |
| 4849 | + message: "User cancelled tool execution.", | |
| 4850 | + } satisfies PermissionResult); | |
| 4851 | + | |
| 4852 | + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | |
| 4853 | + assert.deepEqual( | |
| 4854 | + runtimeEvents.map((event) => event.type), | |
| 4855 | + ["user-input.requested", "user-input.resolved"], | |
| 4856 | + ); | |
| 4857 | + const resolvedEvent = runtimeEvents[1]; | |
| 4858 | + if (resolvedEvent?.type === "user-input.resolved") { | |
| 4859 | + assert.deepEqual(resolvedEvent.payload.answers, {}); | |
| 4860 | + } | |
| 4861 | + }).pipe( | |
| 4862 | + Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 4863 | + Effect.provide(harness.layer), | |
| 4864 | + ); | |
| 4865 | + }); | |
| 4866 | + | |
| 4692 | 4867 | it.effect("stopping a session settles pending user-input waits", () => { |
| 4693 | 4868 | const harness = makeHarness(); |
| 4694 | 4869 | return Effect.gen(function* () { |
| @@ -57,6 +57,10 @@ import { · 56 unmodified lines | ||
| 57 | 57 | getProviderOptionDescriptors, |
| 58 | 58 | resolvePromptInjectedEffort, |
| 59 | 59 | } from "@t3tools/shared/model"; |
| 60 | +import { | |
| 61 | + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, | |
| 62 | + formatClaudeResumeCompactionQuestion, | |
| 63 | +} from "@t3tools/shared/claudeCompaction"; | |
| 60 | 64 | import * as Cause from "effect/Cause"; |
| 61 | 65 | import * as Crypto from "effect/Crypto"; |
| 62 | 66 | import * as DateTime from "effect/DateTime"; |
| @@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: { · 480 unmodified lines | ||
| 543 | 547 | readonly totalProcessedTokens?: number; |
| 544 | 548 | readonly lastUsedTokens?: number; |
| 545 | 549 | readonly compactsAutomatically?: boolean; |
| 550 | + readonly autoCompactThreshold?: number; | |
| 546 | 551 | }): ThreadTokenUsageSnapshot | undefined { |
| 547 | 552 | const activeTokens = finiteNonNegativeInteger(input.activeTokens); |
| 548 | 553 | if (activeTokens === undefined || activeTokens <= 0) { |
| @@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: { · 21 unmodified lines | ||
| 570 | 575 | ...(input.compactsAutomatically !== undefined |
| 571 | 576 | ? { compactsAutomatically: input.compactsAutomatically } |
| 572 | 577 | : {}), |
| 578 | + ...(input.autoCompactThreshold !== undefined | |
| 579 | + ? { autoCompactThreshold: input.autoCompactThreshold } | |
| 580 | + : {}), | |
| 573 | 581 | }; |
| 574 | 582 | } |
| 575 | 583 | |
| @@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot( · 28 unmodified lines | ||
| 604 | 612 | value: SDKControlGetContextUsageResponse, |
| 605 | 613 | totalProcessedTokens?: number, |
| 606 | 614 | ): ThreadTokenUsageSnapshot | undefined { |
| 615 | + const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); | |
| 607 | 616 | return makeClaudeTokenUsageSnapshot({ |
| 608 | 617 | activeTokens: value.totalTokens, |
| 609 | 618 | contextWindow: value.maxTokens, |
| 610 | 619 | ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), |
| 611 | 620 | compactsAutomatically: value.isAutoCompactEnabled, |
| 621 | + ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), | |
| 612 | 622 | }); |
| 613 | 623 | } |
| 614 | 624 | |
| @@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 3338 unmodified lines | ||
| 3953 | 3963 | callbackOptions.signal.addEventListener("abort", onAbort, { |
| 3954 | 3964 | once: true, |
| 3955 | 3965 | }); |
| 3966 | + // The signal may have aborted during the awaited event emissions | |
| 3967 | + // above, before the listener existed; settle now so the dialog | |
| 3968 | + // cannot hang with a lingering pending question. | |
| 3969 | + if (callbackOptions.signal.aborted) { | |
| 3970 | + yield* settleAsAborted; | |
| 3971 | + } | |
| 3956 | 3972 | |
| 3957 | 3973 | // Block until the user provides answers. |
| 3958 | 3974 | const answers = yield* Deferred.await(answersDeferred); |
| @@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 42 unmodified lines | ||
| 4001 | 4017 | } satisfies PermissionResult; |
| 4002 | 4018 | }); |
| 4003 | 4019 | |
| 4020 | + const handleResumeDialog = Effect.fn("handleResumeDialog")(function* ( | |
| 4021 | + request: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[0], | |
| 4022 | + callbackOptions: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[1], | |
| 4023 | + ) { | |
| 4024 | + if (request.dialogKind !== "resume_return") { | |
| 4025 | + return { behavior: "cancelled" as const }; | |
| 4026 | + } | |
| 4027 | + | |
| 4028 | + const context = yield* Ref.get(contextRef); | |
| 4029 | + if (!context) { | |
| 4030 | + return { behavior: "cancelled" as const }; | |
| 4031 | + } | |
| 4032 | + | |
| 4033 | + // The question copy lives in @t3tools/shared/claudeCompaction because | |
| 4034 | + // the web client recognizes this exact text (and the "never" answer) | |
| 4035 | + // to mirror a permanent dismissal. | |
| 4036 | + const question = formatClaudeResumeCompactionQuestion({ | |
| 4037 | + ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0, | |
| 4038 | + estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0, | |
| 4039 | + }); | |
| 4040 | + const result = yield* handleAskUserQuestion( | |
| 4041 | + context, | |
| 4042 | + { | |
| 4043 | + questions: [ | |
| 4044 | + { | |
| 4045 | + header: "Resume session", | |
| 4046 | + question, | |
| 4047 | + options: [ | |
| 4048 | + { | |
| 4049 | + label: "Compact and continue", | |
| 4050 | + description: "Resume with a summary and use fewer tokens.", | |
| 4051 | + }, | |
| 4052 | + { | |
| 4053 | + label: "Keep full history", | |
| 4054 | + description: "Resume without changing the conversation.", | |
| 4055 | + }, | |
| 4056 | + { | |
| 4057 | + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, | |
| 4058 | + description: "Keep full history and skip future resume prompts.", | |
| 4059 | + }, | |
| 4060 | + ], | |
| 4061 | + multiSelect: false, | |
| 4062 | + }, | |
| 4063 | + ], | |
| 4064 | + }, | |
| 4065 | + { | |
| 4066 | + signal: callbackOptions.signal, | |
| 4067 | + ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}), | |
| 4068 | + }, | |
| 4069 | + ); | |
| 4070 | + | |
| 4071 | + if (result.behavior !== "allow") { | |
| 4072 | + return { behavior: "cancelled" as const }; | |
| 4073 | + } | |
| 4074 | + | |
| 4075 | + const answers = result.updatedInput.answers; | |
| 4076 | + const selection = | |
| 4077 | + answers && typeof answers === "object" && !Array.isArray(answers) | |
| 4078 | + ? (answers as Record<string, unknown>)[question] | |
| 4079 | + : undefined; | |
| 4080 | + const action = | |
| 4081 | + selection === "Compact and continue" | |
| 4082 | + ? "compact" | |
| 4083 | + : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER | |
| 4084 | + ? "never" | |
| 4085 | + : "continue"; | |
| 4086 | + | |
| 4087 | + return { behavior: "completed" as const, result: action }; | |
| 4088 | + }); | |
| 4089 | + | |
| 4004 | 4090 | const canUseToolEffect = Effect.fn("canUseTool")(function* ( |
| 4005 | 4091 | toolName: Parameters<CanUseTool>[0], |
| 4006 | 4092 | toolInput: Parameters<CanUseTool>[1], |
| @@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 99 unmodified lines | ||
| 4106 | 4192 | callbackOptions.signal.addEventListener("abort", onAbort, { |
| 4107 | 4193 | once: true, |
| 4108 | 4194 | }); |
| 4195 | + // Same late-listener race as handleAskUserQuestion: the signal may | |
| 4196 | + // have aborted while the request event emissions were awaited. | |
| 4197 | + if (callbackOptions.signal.aborted) { | |
| 4198 | + onAbort(); | |
| 4199 | + } | |
| 4109 | 4200 | |
| 4110 | 4201 | const decision = yield* Deferred.await(decisionDeferred); |
| 4111 | 4202 | pendingApprovals.delete(requestId); |
| @@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 49 unmodified lines | ||
| 4161 | 4252 | |
| 4162 | 4253 | const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => |
| 4163 | 4254 | runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); |
| 4255 | + const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = ( | |
| 4256 | + request, | |
| 4257 | + callbackOptions, | |
| 4258 | + ) => runPromise(handleResumeDialog(request, callbackOptions)); | |
| 4164 | 4259 | |
| 4165 | 4260 | const claudeBinaryPath = claudeSdkExecutablePath; |
| 4166 | 4261 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; |
| @@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 29 unmodified lines | ||
| 4196 | 4291 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 4197 | 4292 | ...(fastMode ? { fastMode: true } : {}), |
| 4198 | 4293 | ...(ultracode ? { ultracode: true } : {}), |
| 4294 | + ...(claudeSettings.autoCompactWindow | |
| 4295 | + ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } | |
| 4296 | + : {}), | |
| 4199 | 4297 | }; |
| 4200 | 4298 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 4201 | 4299 | // The attachments dir grant lets the agent Read/copy pasted images at |
| @@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 26 unmodified lines | ||
| 4228 | 4326 | ...(newSessionId ? { sessionId: newSessionId } : {}), |
| 4229 | 4327 | includePartialMessages: true, |
| 4230 | 4328 | canUseTool, |
| 4329 | + onUserDialog, | |
| 4330 | + supportedDialogKinds: ["resume_return"], | |
| 4231 | 4331 | env: claudeEnvironment, |
| 4232 | 4332 | additionalDirectories, |
| 4233 | 4333 | ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), |
| @@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( · 926 unmodified lines | ||
| 927 | 927 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) |
| 928 | 928 | : undefined; |
| 929 | 929 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); |
| 930 | − const slashCommands = capabilities?.slashCommands ?? []; | |
| 930 | + const slashCommands = [ | |
| 931 | + { | |
| 932 | + name: "compact", | |
| 933 | + description: "Summarize the conversation and reduce context usage", | |
| 934 | + }, | |
| 935 | + ...(capabilities?.slashCommands ?? []), | |
| 936 | + ]; | |
| 931 | 937 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); |
| 932 | 938 | |
| 933 | 939 | if (!capabilities) { |
| @@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => · 105 unmodified lines | ||
| 106 | 106 | homePath: "", |
| 107 | 107 | customModels: [], |
| 108 | 108 | launchArgs: "", |
| 109 | + autoCompactWindow: "", | |
| 109 | 110 | ...overrides, |
| 110 | 111 | }); |
| 111 | 112 | |
| @@ -2151,6 +2151,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 2150 unmodified lines | ||
| 2151 | 2151 | ); |
| 2152 | 2152 | |
| 2153 | 2153 | assert.deepStrictEqual(status.slashCommands, [ |
| 2154 | + { | |
| 2155 | + name: "compact", | |
| 2156 | + description: "Summarize the conversation and reduce context usage", | |
| 2157 | + }, | |
| 2154 | 2158 | { |
| 2155 | 2159 | name: "review", |
| 2156 | 2160 | description: "Review a pull request", |
| @@ -2194,6 +2198,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 37 unmodified lines | ||
| 2194 | 2198 | ); |
| 2195 | 2199 | |
| 2196 | 2200 | assert.deepStrictEqual(status.slashCommands, [ |
| 2201 | + { | |
| 2202 | + name: "compact", | |
| 2203 | + description: "Summarize the conversation and reduce context usage", | |
| 2204 | + }, | |
| 2197 | 2205 | { |
| 2198 | 2206 | name: "ui", |
| 2199 | 2207 | description: "Explore and refine UI", |
| @@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 189 unmodified lines | ||
| 190 | 190 | homePath: "", |
| 191 | 191 | customModels: ["claude-custom"], |
| 192 | 192 | launchArgs: "", |
| 193 | + autoCompactWindow: "", | |
| 193 | 194 | }); |
| 194 | 195 | assert.deepEqual( |
| 195 | 196 | next.textGenerationModelSelection, |
| @@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 385 unmodified lines | ||
| 581 | 582 | homePath: "", |
| 582 | 583 | customModels: [], |
| 583 | 584 | launchArgs: "", |
| 585 | + autoCompactWindow: "", | |
| 584 | 586 | }); |
| 585 | 587 | assert.deepEqual(next.providers.opencode, { |
| 586 | 588 | // OpenCode is disabled by default; this update only touches paths. |
| @@ -82,6 +82,7 @@ import { · 81 unmodified lines | ||
| 82 | 82 | type AtomCommandResult, |
| 83 | 83 | } from "@t3tools/client-runtime/state/runtime"; |
| 84 | 84 | import * as Cause from "effect/Cause"; |
| 85 | +import * as Schema from "effect/Schema"; | |
| 85 | 86 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 86 | 87 | import { isElectron } from "../env"; |
| 87 | 88 | import { readLocalApi } from "../localApi"; |
| @@ -179,6 +180,7 @@ import { · 91 unmodified lines | ||
| 179 | 180 | CheckCircle2Icon, |
| 180 | 181 | ChevronDownIcon, |
| 181 | 182 | GitBranchIcon, |
| 183 | + Minimize2Icon, | |
| 182 | 184 | PaperclipIcon, |
| 183 | 185 | WifiOffIcon, |
| 184 | 186 | } from "lucide-react"; |
| @@ -196,7 +198,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; · 11 unmodified lines | ||
| 196 | 198 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; |
| 197 | 199 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; |
| 198 | 200 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 199 | −import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; | |
| 201 | +import { | |
| 202 | + applyProviderInstanceSettings, | |
| 203 | + deriveProviderInstanceEntries, | |
| 204 | + NO_PROVIDER_MODEL_SELECTION, | |
| 205 | +} from "../providerInstances"; | |
| 200 | 206 | import { |
| 201 | 207 | useClientSettings, |
| 202 | 208 | useClientSettingsHydrated, |
| @@ -223,6 +229,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo · 20 unmodified lines | ||
| 223 | 229 | import { |
| 224 | 230 | beginBackgroundDraftSubmissionByRef, |
| 225 | 231 | clearBackgroundDraftSubmissionByRef, |
| 232 | + composerDraftHasUserContent, | |
| 226 | 233 | type ComposerImageAttachment, |
| 227 | 234 | type DraftThreadEnvMode, |
| 228 | 235 | finalizePromotedDraftThreadByRef, |
| @@ -305,6 +312,12 @@ import { · 76 unmodified lines | ||
| 305 | 312 | useLinkedThreadPullRequest, |
| 306 | 313 | } from "./ThreadStatusIndicators"; |
| 307 | 314 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 315 | +import { | |
| 316 | + hasAvailableClaudeCompactionProvider, | |
| 317 | + hasDismissedResumeCompaction, | |
| 318 | + shouldOfferResumeCompaction, | |
| 319 | +} from "./chat/ContextWindowMeter.logic"; | |
| 320 | +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; | |
| 308 | 321 | import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; |
| 309 | 322 | import { |
| 310 | 323 | DRAFT_HERO_TRANSITION_ANIMATION_ID, |
| @@ -1367,6 +1380,9 @@ function ChatViewContent(props: ChatViewProps) { · 1056 unmodified lines | ||
| 1367 | 1380 | const composerActiveProvider = useComposerDraftStore( |
| 1368 | 1381 | (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, |
| 1369 | 1382 | ); |
| 1383 | + const composerHasUnsentContent = useComposerDraftStore((store) => | |
| 1384 | + composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), | |
| 1385 | + ); | |
| 1370 | 1386 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 1371 | 1387 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 1372 | 1388 | const setComposerDraftTerminalContexts = useComposerDraftStore( |
| @@ -2299,6 +2315,10 @@ function ChatViewContent(props: ChatViewProps) { · 926 unmodified lines | ||
| 2299 | 2315 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 2300 | 2316 | const phase = derivePhase(activeThread?.session ?? null); |
| 2301 | 2317 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 2318 | + const activeContextWindow = useMemo( | |
| 2319 | + () => deriveLatestContextWindowSnapshot(threadActivities), | |
| 2320 | + [threadActivities], | |
| 2321 | + ); | |
| 2302 | 2322 | const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); |
| 2303 | 2323 | const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); |
| 2304 | 2324 | // Native subagent fold: memoized by activity-list identity, shared by the |
| @@ -2762,6 +2782,29 @@ function ChatViewContent(props: ChatViewProps) { · 457 unmodified lines | ||
| 2762 | 2782 | activeThread?.modelSelection.instanceId ?? |
| 2763 | 2783 | activeProject?.defaultModelSelection?.instanceId ?? |
| 2764 | 2784 | null; |
| 2785 | + const compactionProviderAvailable = useMemo( | |
| 2786 | + () => | |
| 2787 | + hasAvailableClaudeCompactionProvider({ | |
| 2788 | + providers: applyProviderInstanceSettings( | |
| 2789 | + deriveProviderInstanceEntries(providerStatuses), | |
| 2790 | + settings, | |
| 2791 | + ), | |
| 2792 | + instanceId: activeProviderInstanceId, | |
| 2793 | + lockedInstanceId: lockedProvider | |
| 2794 | + ? (activeThread?.session?.providerInstanceId ?? | |
| 2795 | + activeThread?.modelSelection.instanceId ?? | |
| 2796 | + null) | |
| 2797 | + : null, | |
| 2798 | + }), | |
| 2799 | + [ | |
| 2800 | + activeProviderInstanceId, | |
| 2801 | + activeThread?.modelSelection.instanceId, | |
| 2802 | + activeThread?.session?.providerInstanceId, | |
| 2803 | + lockedProvider, | |
| 2804 | + providerStatuses, | |
| 2805 | + settings, | |
| 2806 | + ], | |
| 2807 | + ); | |
| 2765 | 2808 | const activeProviderStatus = useMemo(() => { |
| 2766 | 2809 | if (activeProviderInstanceId) { |
| 2767 | 2810 | return ( |
| @@ -2771,6 +2814,25 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | ||
| 2771 | 2814 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); |
| 2772 | 2815 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; |
| 2773 | 2816 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); |
| 2817 | + const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = | |
| 2818 | + useLocalStorage( | |
| 2819 | + `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, | |
| 2820 | + false, | |
| 2821 | + Schema.Boolean, | |
| 2822 | + ); | |
| 2823 | + const nativeResumeCompactionDismissed = useMemo( | |
| 2824 | + () => hasDismissedResumeCompaction(threadActivities), | |
| 2825 | + [threadActivities], | |
| 2826 | + ); | |
| 2827 | + useEffect(() => { | |
| 2828 | + if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { | |
| 2829 | + setResumeCompactionPermanentlyDismissed(true); | |
| 2830 | + } | |
| 2831 | + }, [ | |
| 2832 | + nativeResumeCompactionDismissed, | |
| 2833 | + resumeCompactionPermanentlyDismissed, | |
| 2834 | + setResumeCompactionPermanentlyDismissed, | |
| 2835 | + ]); | |
| 2774 | 2836 | const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); |
| 2775 | 2837 | const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< |
| 2776 | 2838 | string | null |
| @@ -4515,18 +4577,6 @@ function ChatViewContent(props: ChatViewProps) { · 1738 unmodified lines | ||
| 4515 | 4577 | // Dismissal lives in a module-level set (survives remounts); this tick just |
| 4516 | 4578 | // forces a re-render so the banner leaves immediately. |
| 4517 | 4579 | const [, setBranchMismatchDismissTick] = useState(0); |
| 4518 | − const composerHasDraftContent = useComposerDraftStore((store) => { | |
| 4519 | − const draft = store.getComposerDraft(composerDraftTarget); | |
| 4520 | − return Boolean( | |
| 4521 | − draft && | |
| 4522 | − (draft.prompt.trim().length > 0 || | |
| 4523 | − draft.images.length > 0 || | |
| 4524 | − draft.terminalContexts.length > 0 || | |
| 4525 | − draft.elementContexts.length > 0 || | |
| 4526 | − draft.previewAnnotations.length > 0 || | |
| 4527 | − draft.reviewComments.length > 0), | |
| 4528 | − ); | |
| 4529 | − }); | |
| 4530 | 4580 | const activeBranchMismatchKey = branchMismatchKey( |
| 4531 | 4581 | activeThread?.id ?? null, |
| 4532 | 4582 | localCheckoutBranchMismatch, |
| @@ -4534,7 +4584,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 4534 | 4584 | const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ |
| 4535 | 4585 | hasMismatch: localCheckoutBranchMismatch !== null, |
| 4536 | 4586 | isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), |
| 4537 | − composerHasContent: composerHasDraftContent, | |
| 4587 | + composerHasContent: composerHasUnsentContent, | |
| 4538 | 4588 | wasShownForCurrentMismatch: |
| 4539 | 4589 | revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, |
| 4540 | 4590 | }); |
| @@ -4758,6 +4808,107 @@ function ChatViewContent(props: ChatViewProps) { · 217 unmodified lines | ||
| 4758 | 4808 | isUnsnoozing, |
| 4759 | 4809 | isUnsettling, |
| 4760 | 4810 | ]); |
| 4811 | + // Session-scoped dismissals, one key per (thread, snapshot). A set rather | |
| 4812 | + // than a single slot so dismissing the banner on one thread does not | |
| 4813 | + // resurface it on another thread dismissed earlier. | |
| 4814 | + const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState< | |
| 4815 | + ReadonlySet<string> | |
| 4816 | + >(new Set()); | |
| 4817 | + const resumeCompactionKey = | |
| 4818 | + activeThread && activeContextWindow | |
| 4819 | + ? `${activeThread.id}:${activeContextWindow.updatedAt}` | |
| 4820 | + : null; | |
| 4821 | + const compactDisabled = | |
| 4822 | + !activeThread || | |
| 4823 | + !activeProject || | |
| 4824 | + !isServerThread || | |
| 4825 | + selectedProvider !== "claudeAgent" || | |
| 4826 | + !compactionProviderAvailable || | |
| 4827 | + isWorking || | |
| 4828 | + threadDetailLoading || | |
| 4829 | + isPreparingWorktree || | |
| 4830 | + activeEnvironmentUnavailable || | |
| 4831 | + feedbackUploading || | |
| 4832 | + pendingApprovals.length > 0 || | |
| 4833 | + pendingUserInputs.length > 0 || | |
| 4834 | + showPlanFollowUpPrompt || | |
| 4835 | + composerHasUnsentContent; | |
| 4836 | + const compactDisabledReason = compactDisabled | |
| 4837 | + ? composerHasUnsentContent | |
| 4838 | + ? "Send or clear your draft before compacting" | |
| 4839 | + : !activeProject | |
| 4840 | + ? "Choose a project before compacting" | |
| 4841 | + : !compactionProviderAvailable | |
| 4842 | + ? "Enable a Claude provider before compacting" | |
| 4843 | + : "Compacting is unavailable right now" | |
| 4844 | + : null; | |
| 4845 | + const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | |
| 4846 | + if ( | |
| 4847 | + !activeThread || | |
| 4848 | + !activeContextWindow || | |
| 4849 | + resumeCompactionKey === null || | |
| 4850 | + dismissedResumeCompactionKeys.has(resumeCompactionKey) || | |
| 4851 | + resumeCompactionPermanentlyDismissed || | |
| 4852 | + nativeResumeCompactionDismissed || | |
| 4853 | + pendingUserInputs.length > 0 || | |
| 4854 | + phase === "running" || | |
| 4855 | + !shouldOfferResumeCompaction({ | |
| 4856 | + provider: selectedProvider, | |
| 4857 | + usedTokens: activeContextWindow.usedTokens, | |
| 4858 | + updatedAt: activeContextWindow.updatedAt, | |
| 4859 | + now: `${nowMinute}:00.000Z`, | |
| 4860 | + }) | |
| 4861 | + ) { | |
| 4862 | + return null; | |
| 4863 | + } | |
| 4864 | + | |
| 4865 | + const dismiss = () => | |
| 4866 | + setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey)); | |
| 4867 | + const compactAction = ( | |
| 4868 | + <Button | |
| 4869 | + size="xs" | |
| 4870 | + variant="outline" | |
| 4871 | + disabled={compactDisabled} | |
| 4872 | + onClick={() => { | |
| 4873 | + if (compactDisabled) return; | |
| 4874 | + composerRef.current?.compactContext(); | |
| 4875 | + }} | |
| 4876 | + > | |
| 4877 | + Compact | |
| 4878 | + </Button> | |
| 4879 | + ); | |
| 4880 | + return { | |
| 4881 | + id: `resume-compaction:${resumeCompactionKey}`, | |
| 4882 | + variant: "info", | |
| 4883 | + icon: <Minimize2Icon />, | |
| 4884 | + title: "Resume with less context", | |
| 4885 | + description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`, | |
| 4886 | + actions: compactDisabledReason ? ( | |
| 4887 | + <Tooltip> | |
| 4888 | + <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} /> | |
| 4889 | + <TooltipPopup side="top">{compactDisabledReason}</TooltipPopup> | |
| 4890 | + </Tooltip> | |
| 4891 | + ) : ( | |
| 4892 | + compactAction | |
| 4893 | + ), | |
| 4894 | + dismissLabel: "Keep full history", | |
| 4895 | + onDismiss: dismiss, | |
| 4896 | + }; | |
| 4897 | + }, [ | |
| 4898 | + activeContextWindow, | |
| 4899 | + activeThread, | |
| 4900 | + compactDisabled, | |
| 4901 | + compactDisabledReason, | |
| 4902 | + composerRef, | |
| 4903 | + dismissedResumeCompactionKeys, | |
| 4904 | + nativeResumeCompactionDismissed, | |
| 4905 | + nowMinute, | |
| 4906 | + pendingUserInputs.length, | |
| 4907 | + phase, | |
| 4908 | + resumeCompactionKey, | |
| 4909 | + resumeCompactionPermanentlyDismissed, | |
| 4910 | + selectedProvider, | |
| 4911 | + ]); | |
| 4761 | 4912 | const handleRestoreThreadBranch = useCallback(() => { |
| 4762 | 4913 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 4763 | 4914 | setBranchRestoreConfirmOpen(true); |
| @@ -4772,6 +4923,8 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines | ||
| 4772 | 4923 | const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); |
| 4773 | 4924 | const backgroundLivenessItems = |
| 4774 | 4925 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; |
| 4926 | + const resumeCompactionItems = | |
| 4927 | + resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; | |
| 4775 | 4928 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; |
| 4776 | 4929 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 4777 | 4930 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| @@ -4779,6 +4932,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 4779 | 4932 | ...urgentSystemItems, |
| 4780 | 4933 | ...backgroundLivenessItems, |
| 4781 | 4934 | ...calmSystemItems, |
| 4935 | + ...resumeCompactionItems, | |
| 4782 | 4936 | ...wokeThreadItems, |
| 4783 | 4937 | ...parkedThreadItems, |
| 4784 | 4938 | ]; |
| @@ -4787,6 +4941,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines | ||
| 4787 | 4941 | ...urgentSystemItems, |
| 4788 | 4942 | ...backgroundLivenessItems, |
| 4789 | 4943 | ...calmSystemItems, |
| 4944 | + ...resumeCompactionItems, | |
| 4790 | 4945 | ...wokeThreadItems, |
| 4791 | 4946 | { |
| 4792 | 4947 | id: `branch-mismatch:${activeBranchMismatchKey}`, |
| @@ -4836,6 +4991,7 @@ function ChatViewContent(props: ChatViewProps) { · 43 unmodified lines | ||
| 4836 | 4991 | isRestoringThreadBranch, |
| 4837 | 4992 | localCheckoutBranchMismatch, |
| 4838 | 4993 | parkedThreadBannerItem, |
| 4994 | + resumeCompactionBannerItem, | |
| 4839 | 4995 | showBranchMismatchBanner, |
| 4840 | 4996 | systemComposerBannerItems, |
| 4841 | 4997 | wokeThreadBannerItem, |
| @@ -6916,7 +7072,9 @@ function ChatViewContent(props: ChatViewProps) { · 2074 unmodified lines | ||
| 6916 | 7072 | activeProject?.defaultModelSelection |
| 6917 | 7073 | } |
| 6918 | 7074 | activeThreadModelSelection={activeThread?.modelSelection} |
| 6919 | − activeThreadActivities={activeThread?.activities} | |
| 7075 | + activeContextWindow={activeContextWindow} | |
| 7076 | + compactDisabled={compactDisabled} | |
| 7077 | + compactDisabledReason={compactDisabledReason} | |
| 6920 | 7078 | resolvedTheme={resolvedTheme} |
| 6921 | 7079 | settings={settings} |
| 6922 | 7080 | keybindings={keybindings} |
| @@ -266,7 +266,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; · 265 unmodified lines | ||
| 266 | 266 | import type { SessionPhase, Thread } from "../../types"; |
| 267 | 267 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 268 | 268 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; |
| 269 | −import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; | |
| 269 | +import type { ContextWindowSnapshot } from "../../lib/contextWindow"; | |
| 270 | 270 | import { |
| 271 | 271 | formatProviderSkillDisplayName, |
| 272 | 272 | getProviderSlashCommandsForSlashMenu, |
| @@ -438,7 +438,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop · 165 unmodified lines | ||
| 438 | 438 | |
| 439 | 439 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { |
| 440 | 440 | compact: boolean; |
| 441 | − activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>; | |
| 441 | + activeContextWindow: ContextWindowSnapshot | null; | |
| 442 | 442 | activeThreadModelDisplayName: string | null; |
| 443 | 443 | isPreparingWorktree: boolean; |
| 444 | 444 | pendingAction: { |
| @@ -461,6 +461,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 16 unmodified lines | ||
| 461 | 461 | onPreviousPendingQuestion: () => void; |
| 462 | 462 | onInterrupt: () => void; |
| 463 | 463 | onImplementPlanInNewThread: () => void; |
| 464 | + onCompactContext?: (() => void) | undefined; | |
| 465 | + compactDisabled: boolean; | |
| 466 | + compactDisabledReason: string | null; | |
| 464 | 467 | }) { |
| 465 | 468 | return ( |
| 466 | 469 | <> |
| @@ -468,6 +471,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 1 unmodified line | ||
| 468 | 471 | <ContextWindowMeter |
| 469 | 472 | usage={props.activeContextWindow} |
| 470 | 473 | modelDisplayName={props.activeThreadModelDisplayName} |
| 474 | + onCompact={props.onCompactContext} | |
| 475 | + compactDisabled={props.compactDisabled} | |
| 476 | + compactDisabledReason={props.compactDisabledReason} | |
| 471 | 477 | /> |
| 472 | 478 | ) : null} |
| 473 | 479 | {props.isPreparingWorktree ? ( |
| @@ -507,6 +513,7 @@ export interface ChatComposerHandle { · 33 unmodified lines | ||
| 507 | 513 | openModelPicker: () => void; |
| 508 | 514 | toggleModelPicker: () => void; |
| 509 | 515 | isModelPickerOpen: () => boolean; |
| 516 | + compactContext: () => void; | |
| 510 | 517 | readSnapshot: () => { |
| 511 | 518 | value: string; |
| 512 | 519 | cursor: number; |
| @@ -609,7 +616,9 @@ export interface ChatComposerProps { · 96 unmodified lines | ||
| 609 | 616 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 610 | 617 | |
| 611 | 618 | // Context window |
| 612 | − activeThreadActivities: Thread["activities"] | undefined; | |
| 619 | + activeContextWindow: ContextWindowSnapshot | null; | |
| 620 | + compactDisabled: boolean; | |
| 621 | + compactDisabledReason: string | null; | |
| 613 | 622 | |
| 614 | 623 | // Misc |
| 615 | 624 | resolvedTheme: "light" | "dark"; |
| @@ -701,7 +710,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 85 unmodified lines | ||
| 701 | 710 | providerStatuses, |
| 702 | 711 | activeProjectDefaultModelSelection, |
| 703 | 712 | activeThreadModelSelection, |
| 704 | − activeThreadActivities, | |
| 713 | + activeContextWindow, | |
| 714 | + compactDisabled, | |
| 715 | + compactDisabledReason, | |
| 705 | 716 | resolvedTheme, |
| 706 | 717 | settings, |
| 707 | 718 | keybindings, |
| @@ -914,6 +925,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 206 unmodified lines | ||
| 914 | 925 | [providerInstanceEntries, selectedInstanceId], |
| 915 | 926 | ); |
| 916 | 927 | const noProviderAvailable = selectedProviderEntry === undefined; |
| 928 | + const resolvedCompactDisabledReason = | |
| 929 | + compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); | |
| 917 | 930 | // The driver kind follows the instance that will actually run the turn, |
| 918 | 931 | // which can differ from the persisted selection when that selection is |
| 919 | 932 | // disabled. |
| @@ -1004,10 +1017,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 84 unmodified lines | ||
| 1004 | 1017 | // ------------------------------------------------------------------ |
| 1005 | 1018 | // Context window |
| 1006 | 1019 | // ------------------------------------------------------------------ |
| 1007 | − const activeContextWindow = useMemo( | |
| 1008 | − () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), | |
| 1009 | − [activeThreadActivities], | |
| 1010 | − ); | |
| 1011 | 1020 | const activeThreadModelDisplayName = useMemo( |
| 1012 | 1021 | () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), |
| 1013 | 1022 | [activeThreadModelSelection, modelOptionsByInstance], |
| @@ -1073,8 +1082,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 59 unmodified lines | ||
| 1073 | 1082 | /** |
| 1074 | 1083 | * Count of pasted images still being compressed, per thread. Reserved |
| 1075 | 1084 | * against the attachment limit so concurrent pastes can't overshoot it, |
| 1076 | − * and checked by `submitComposer` so a send can't race an image into the | |
| 1077 | − * next draft. | |
| 1085 | + * and checked before sending or compacting so an image cannot move into | |
| 1086 | + * the next draft. | |
| 1078 | 1087 | */ |
| 1079 | 1088 | const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map()); |
| 1080 | 1089 | |
| @@ -1986,6 +1995,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 905 unmodified lines | ||
| 1986 | 1995 | shouldBlurMobileComposerOnSubmit, |
| 1987 | 1996 | ], |
| 1988 | 1997 | ); |
| 1998 | + const compactThreadContext = useCallback(() => { | |
| 1999 | + if ( | |
| 2000 | + compactDisabled || | |
| 2001 | + noProviderAvailable || | |
| 2002 | + composerSendState.hasSendableContent || | |
| 2003 | + activePendingApproval !== null || | |
| 2004 | + pendingUserInputs.length > 0 || | |
| 2005 | + phase === "running" || | |
| 2006 | + isSendBusy || | |
| 2007 | + isConnecting || | |
| 2008 | + !activeThreadId | |
| 2009 | + ) { | |
| 2010 | + return; | |
| 2011 | + } | |
| 2012 | + // The compact buttons cannot see the compression counter (it lives in | |
| 2013 | + // a ref), so they render enabled during a paste; toast instead of | |
| 2014 | + // silently ignoring the click. | |
| 2015 | + if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) { | |
| 2016 | + toastManager.add({ | |
| 2017 | + type: "info", | |
| 2018 | + title: "Still compressing a pasted image.", | |
| 2019 | + description: "Compact again once its thumbnail appears.", | |
| 2020 | + }); | |
| 2021 | + return; | |
| 2022 | + } | |
| 2023 | + | |
| 2024 | + promptRef.current = "/compact"; | |
| 2025 | + setComposerDraftPrompt(composerDraftTarget, "/compact"); | |
| 2026 | + submitComposer(); | |
| 2027 | + // A blocked dispatch (busy send ref, provider preflight rejection) | |
| 2028 | + // would leave the injected "/compact" behind as if the user typed it. | |
| 2029 | + // Clearing here is safe even when the send did dispatch: the send | |
| 2030 | + // snapshots its prompt synchronously and clears the draft itself. | |
| 2031 | + if (promptRef.current === "/compact") { | |
| 2032 | + promptRef.current = ""; | |
| 2033 | + setComposerDraftPrompt(composerDraftTarget, ""); | |
| 2034 | + } | |
| 2035 | + }, [ | |
| 2036 | + activePendingApproval, | |
| 2037 | + activeThreadId, | |
| 2038 | + compactDisabled, | |
| 2039 | + composerDraftTarget, | |
| 2040 | + composerSendState.hasSendableContent, | |
| 2041 | + isConnecting, | |
| 2042 | + isSendBusy, | |
| 2043 | + noProviderAvailable, | |
| 2044 | + pendingUserInputs.length, | |
| 2045 | + phase, | |
| 2046 | + promptRef, | |
| 2047 | + setComposerDraftPrompt, | |
| 2048 | + submitComposer, | |
| 2049 | + ]); | |
| 1989 | 2050 | const expandMobileComposer = useCallback(() => { |
| 1990 | 2051 | if (composerBlurFrameRef.current !== null) { |
| 1991 | 2052 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| @@ -2764,6 +2825,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 772 unmodified lines | ||
| 2764 | 2825 | toggleModelPicker: () => { |
| 2765 | 2826 | setIsComposerModelPickerOpen((open) => !open); |
| 2766 | 2827 | }, |
| 2828 | + compactContext: compactThreadContext, | |
| 2767 | 2829 | isModelPickerOpen: () => isComposerModelPickerOpen, |
| 2768 | 2830 | readSnapshot: () => { |
| 2769 | 2831 | return readComposerSnapshot(); |
| @@ -2875,6 +2937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 105 unmodified lines | ||
| 2875 | 2937 | selectedPromptEffort, |
| 2876 | 2938 | selectedProvider, |
| 2877 | 2939 | selectedProviderModels, |
| 2940 | + compactThreadContext, | |
| 2878 | 2941 | ], |
| 2879 | 2942 | ); |
| 2880 | 2943 | |
| @@ -3517,6 +3580,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 636 unmodified lines | ||
| 3517 | 3580 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} |
| 3518 | 3581 | onInterrupt={handleInterruptPrimaryAction} |
| 3519 | 3582 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} |
| 3583 | + compactDisabled={ | |
| 3584 | + compactDisabled || noProviderAvailable || isSendBusy || isConnecting | |
| 3585 | + } | |
| 3586 | + compactDisabledReason={resolvedCompactDisabledReason} | |
| 3587 | + {...(selectedProvider === "claudeAgent" | |
| 3588 | + ? { onCompactContext: compactThreadContext } | |
| 3589 | + : {})} | |
| 3520 | 3590 | /> |
| 3521 | 3591 | </div> |
| 3522 | 3592 | </div> |
| @@ -77,4 +77,32 @@ describe("ComposerBannerStack", () => { · 76 unmodified lines | ||
| 77 | 77 | expect(markup).toContain("branch-surface"); |
| 78 | 78 | expect(markup).toContain("branch-actions"); |
| 79 | 79 | }); |
| 80 | + | |
| 81 | + it("renders a disabled compaction action on the shared accessible banner surface", () => { | |
| 82 | + const markup = renderToStaticMarkup( | |
| 83 | + <ComposerBannerStack | |
| 84 | + items={[ | |
| 85 | + { | |
| 86 | + id: "resume-compaction", | |
| 87 | + variant: "info", | |
| 88 | + icon: <span aria-hidden="true">!</span>, | |
| 89 | + title: "Resume with less context", | |
| 90 | + description: "250k tokens from an older session", | |
| 91 | + actions: ( | |
| 92 | + <button type="button" disabled> | |
| 93 | + Compact | |
| 94 | + </button> | |
| 95 | + ), | |
| 96 | + dismissLabel: "Keep full history", | |
| 97 | + onDismiss: () => {}, | |
| 98 | + }, | |
| 99 | + ]} | |
| 100 | + />, | |
| 101 | + ); | |
| 102 | + | |
| 103 | + expect(markup).toContain('role="alert"'); | |
| 104 | + expect(markup).toContain("chat-composer-drawer-attached"); | |
| 105 | + expect(markup).toContain('disabled=""'); | |
| 106 | + expect(markup).toContain('aria-label="Keep full history"'); | |
| 107 | + }); | |
| 80 | 108 | }); |
| @@ -1,10 +1,83 @@ | ||
| 1 | −import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 1 | +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | +import { deriveProviderInstanceEntries } from "../../providerInstances"; | |
| 3 | 4 | import { |
| 4 | 5 | formatContextWindowCompactionMessage, |
| 6 | + hasAvailableClaudeCompactionProvider, | |
| 7 | + hasDismissedResumeCompaction, | |
| 5 | 8 | resolveContextWindowModelDisplayName, |
| 9 | + shouldOfferResumeCompaction, | |
| 6 | 10 | } from "./ContextWindowMeter.logic"; |
| 7 | 11 | |
| 12 | +function claudeProvider(input: { | |
| 13 | + instanceId: string; | |
| 14 | + continuationGroupKey: string; | |
| 15 | + enabled?: boolean; | |
| 16 | +}): ServerProvider { | |
| 17 | + return { | |
| 18 | + instanceId: ProviderInstanceId.make(input.instanceId), | |
| 19 | + driver: ProviderDriverKind.make("claudeAgent"), | |
| 20 | + continuation: { groupKey: input.continuationGroupKey }, | |
| 21 | + enabled: input.enabled ?? true, | |
| 22 | + installed: true, | |
| 23 | + version: null, | |
| 24 | + status: "ready", | |
| 25 | + auth: { status: "authenticated" }, | |
| 26 | + checkedAt: "2026-08-24T12:00:00.000Z", | |
| 27 | + models: [], | |
| 28 | + slashCommands: [], | |
| 29 | + skills: [], | |
| 30 | + }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +describe("hasAvailableClaudeCompactionProvider", () => { | |
| 34 | + const originalInstanceId = ProviderInstanceId.make("claude_original"); | |
| 35 | + | |
| 36 | + it("rejects a fallback in a different locked continuation group", () => { | |
| 37 | + const providers = deriveProviderInstanceEntries([ | |
| 38 | + claudeProvider({ | |
| 39 | + instanceId: originalInstanceId, | |
| 40 | + continuationGroupKey: "claude:home:/original", | |
| 41 | + enabled: false, | |
| 42 | + }), | |
| 43 | + claudeProvider({ | |
| 44 | + instanceId: "claude_other", | |
| 45 | + continuationGroupKey: "claude:home:/other", | |
| 46 | + }), | |
| 47 | + ]); | |
| 48 | + | |
| 49 | + expect( | |
| 50 | + hasAvailableClaudeCompactionProvider({ | |
| 51 | + providers, | |
| 52 | + instanceId: originalInstanceId, | |
| 53 | + lockedInstanceId: originalInstanceId, | |
| 54 | + }), | |
| 55 | + ).toBe(false); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it("accepts an enabled fallback in the locked continuation group", () => { | |
| 59 | + const providers = deriveProviderInstanceEntries([ | |
| 60 | + claudeProvider({ | |
| 61 | + instanceId: originalInstanceId, | |
| 62 | + continuationGroupKey: "claude:home:/original", | |
| 63 | + enabled: false, | |
| 64 | + }), | |
| 65 | + claudeProvider({ | |
| 66 | + instanceId: "claude_fallback", | |
| 67 | + continuationGroupKey: "claude:home:/original", | |
| 68 | + }), | |
| 69 | + ]); | |
| 70 | + | |
| 71 | + expect( | |
| 72 | + hasAvailableClaudeCompactionProvider({ | |
| 73 | + providers, | |
| 74 | + instanceId: originalInstanceId, | |
| 75 | + lockedInstanceId: originalInstanceId, | |
| 76 | + }), | |
| 77 | + ).toBe(true); | |
| 78 | + }); | |
| 79 | +}); | |
| 80 | + | |
| 8 | 81 | describe("resolveContextWindowModelDisplayName", () => { |
| 9 | 82 | it("uses the selected model from the exact provider instance", () => { |
| 10 | 83 | const primaryInstanceId = ProviderInstanceId.make("codex"); |
| @@ -55,4 +128,109 @@ describe("formatContextWindowCompactionMessage", () => { · 44 unmodified lines | ||
| 55 | 128 | "Context compacts automatically when needed.", |
| 56 | 129 | ); |
| 57 | 130 | }); |
| 131 | + | |
| 132 | + it("shows the configured auto-compaction threshold", () => { | |
| 133 | + expect(formatContextWindowCompactionMessage("Claude Sonnet 5", 300_000)).toBe( | |
| 134 | + "Compacts automatically at 300,000 tokens.", | |
| 135 | + ); | |
| 136 | + }); | |
| 137 | +}); | |
| 138 | + | |
| 139 | +describe("shouldOfferResumeCompaction", () => { | |
| 140 | + const now = "2026-08-24T12:00:00.000Z"; | |
| 141 | + | |
| 142 | + it("matches Claude's old-session age and context thresholds", () => { | |
| 143 | + expect( | |
| 144 | + shouldOfferResumeCompaction({ | |
| 145 | + provider: "claudeAgent", | |
| 146 | + usedTokens: 100_000, | |
| 147 | + updatedAt: "2026-08-24T10:50:00.000Z", | |
| 148 | + now, | |
| 149 | + }), | |
| 150 | + ).toBe(true); | |
| 151 | + }); | |
| 152 | + | |
| 153 | + it("does not prompt for recent or smaller sessions", () => { | |
| 154 | + expect( | |
| 155 | + shouldOfferResumeCompaction({ | |
| 156 | + provider: "claudeAgent", | |
| 157 | + usedTokens: 99_999, | |
| 158 | + updatedAt: "2026-08-24T10:00:00.000Z", | |
| 159 | + now, | |
| 160 | + }), | |
| 161 | + ).toBe(false); | |
| 162 | + expect( | |
| 163 | + shouldOfferResumeCompaction({ | |
| 164 | + provider: "claudeAgent", | |
| 165 | + usedTokens: 200_000, | |
| 166 | + updatedAt: "2026-08-24T10:51:00.000Z", | |
| 167 | + now, | |
| 168 | + }), | |
| 169 | + ).toBe(false); | |
| 170 | + }); | |
| 171 | + | |
| 172 | + it("does not show Claude's resume prompt for another provider", () => { | |
| 173 | + expect( | |
| 174 | + shouldOfferResumeCompaction({ | |
| 175 | + provider: "codex", | |
| 176 | + usedTokens: 300_000, | |
| 177 | + updatedAt: "2026-08-24T09:00:00.000Z", | |
| 178 | + now, | |
| 179 | + }), | |
| 180 | + ).toBe(false); | |
| 181 | + }); | |
| 182 | +}); | |
| 183 | + | |
| 184 | +describe("hasDismissedResumeCompaction", () => { | |
| 185 | + it("recognizes the native resume dialog's permanent dismissal", () => { | |
| 186 | + expect( | |
| 187 | + hasDismissedResumeCompaction([ | |
| 188 | + { | |
| 189 | + kind: "user-input.resolved", | |
| 190 | + payload: { | |
| 191 | + answers: { | |
| 192 | + "This session is 2h 0m old and uses 250,000 tokens. Compact it before continuing?": | |
| 193 | + "Don't ask again", | |
| 194 | + }, | |
| 195 | + }, | |
| 196 | + }, | |
| 197 | + ]), | |
| 198 | + ).toBe(true); | |
| 199 | + }); | |
| 200 | + | |
| 201 | + it("ignores the same answer on an unrelated question", () => { | |
| 202 | + expect( | |
| 203 | + hasDismissedResumeCompaction([ | |
| 204 | + { | |
| 205 | + kind: "user-input.resolved", | |
| 206 | + payload: { answers: { "Show this setup reminder?": "Don't ask again" } }, | |
| 207 | + }, | |
| 208 | + ]), | |
| 209 | + ).toBe(false); | |
| 210 | + }); | |
| 211 | + | |
| 212 | + it("ignores unrelated questions that end with Claude's compaction prompt", () => { | |
| 213 | + expect( | |
| 214 | + hasDismissedResumeCompaction([ | |
| 215 | + { | |
| 216 | + kind: "user-input.resolved", | |
| 217 | + payload: { | |
| 218 | + answers: { | |
| 219 | + "The build cache is large. Compact it before continuing?": "Don't ask again", | |
| 220 | + }, | |
| 221 | + }, | |
| 222 | + }, | |
| 223 | + ]), | |
| 224 | + ).toBe(false); | |
| 225 | + }); | |
| 226 | + | |
| 227 | + it("ignores pending questions and malformed resolved payloads", () => { | |
| 228 | + expect( | |
| 229 | + hasDismissedResumeCompaction([ | |
| 230 | + { kind: "user-input.requested", payload: { answers: { question: "Don't ask again" } } }, | |
| 231 | + { kind: "user-input.resolved", payload: null }, | |
| 232 | + { kind: "user-input.resolved", payload: { answers: ["Don't ask again"] } }, | |
| 233 | + ]), | |
| 234 | + ).toBe(false); | |
| 235 | + }); | |
| 58 | 236 | }); |
| @@ -1,6 +1,81 @@ | ||
| 1 | 1 | import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | +import { | |
| 3 | + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, | |
| 4 | + isClaudeResumeCompactionQuestion, | |
| 5 | +} from "@t3tools/shared/claudeCompaction"; | |
| 6 | +import { | |
| 7 | + resolveSelectableProviderInstanceEntry, | |
| 8 | + type ProviderInstanceEntry, | |
| 9 | +} from "../../providerInstances"; | |
| 2 | 10 | import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; |
| 3 | 11 | |
| 12 | +export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; | |
| 13 | +export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; | |
| 14 | + | |
| 15 | +export function hasAvailableClaudeCompactionProvider(input: { | |
| 16 | + readonly providers: ReadonlyArray<ProviderInstanceEntry>; | |
| 17 | + readonly instanceId: ProviderInstanceId | null; | |
| 18 | + readonly lockedInstanceId: ProviderInstanceId | null; | |
| 19 | +}): boolean { | |
| 20 | + const claudeProviders = input.providers.filter( | |
| 21 | + (provider) => provider.driverKind === "claudeAgent", | |
| 22 | + ); | |
| 23 | + const lockedContinuationGroupKey = input.lockedInstanceId | |
| 24 | + ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) | |
| 25 | + ?.continuationGroupKey | |
| 26 | + : undefined; | |
| 27 | + const compatibleProviders = lockedContinuationGroupKey | |
| 28 | + ? claudeProviders.filter( | |
| 29 | + (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, | |
| 30 | + ) | |
| 31 | + : claudeProviders; | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== | |
| 35 | + undefined | |
| 36 | + ); | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function hasDismissedResumeCompaction( | |
| 40 | + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, | |
| 41 | +): boolean { | |
| 42 | + return activities.some((activity) => { | |
| 43 | + if (activity.kind !== "user-input.resolved") return false; | |
| 44 | + const payload = activity.payload; | |
| 45 | + if (!payload || typeof payload !== "object") return false; | |
| 46 | + const answers = (payload as { readonly answers?: unknown }).answers; | |
| 47 | + if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false; | |
| 48 | + | |
| 49 | + return Object.entries(answers).some( | |
| 50 | + ([question, answer]) => | |
| 51 | + isClaudeResumeCompactionQuestion(question) && | |
| 52 | + answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, | |
| 53 | + ); | |
| 54 | + }); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function shouldOfferResumeCompaction(input: { | |
| 58 | + readonly provider: string | null | undefined; | |
| 59 | + readonly usedTokens: number | null | undefined; | |
| 60 | + readonly updatedAt: string | null | undefined; | |
| 61 | + readonly now: string; | |
| 62 | +}): boolean { | |
| 63 | + if ( | |
| 64 | + input.provider !== "claudeAgent" || | |
| 65 | + (input.usedTokens ?? 0) < CLAUDE_RESUME_COMPACTION_TOKENS | |
| 66 | + ) { | |
| 67 | + return false; | |
| 68 | + } | |
| 69 | + | |
| 70 | + const updatedAt = Date.parse(input.updatedAt ?? ""); | |
| 71 | + const now = Date.parse(input.now); | |
| 72 | + return ( | |
| 73 | + Number.isFinite(updatedAt) && | |
| 74 | + Number.isFinite(now) && | |
| 75 | + now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000 | |
| 76 | + ); | |
| 77 | +} | |
| 78 | + | |
| 4 | 79 | export function resolveContextWindowModelDisplayName( |
| 5 | 80 | selection: ModelSelection | null | undefined, |
| 6 | 81 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>, |
| @@ -18,7 +93,11 @@ export function resolveContextWindowModelDisplayName( · 11 unmodified lines | ||
| 18 | 93 | |
| 19 | 94 | export function formatContextWindowCompactionMessage( |
| 20 | 95 | modelDisplayName: string | null | undefined, |
| 96 | + autoCompactThreshold?: number | null, | |
| 21 | 97 | ): string { |
| 98 | + if (typeof autoCompactThreshold === "number" && autoCompactThreshold > 0) { | |
| 99 | + return `Compacts automatically at ${autoCompactThreshold.toLocaleString("en-US")} tokens.`; | |
| 100 | + } | |
| 22 | 101 | return modelDisplayName |
| 23 | 102 | ? `Context for ${modelDisplayName} compacts automatically when needed.` |
| 24 | 103 | : "Context compacts automatically when needed."; |
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { EventId, TurnId } from "@t3tools/contracts"; | |
| 2 | +import type { ReactNode } from "react"; | |
| 3 | +import { renderToStaticMarkup } from "react-dom/server"; | |
| 4 | +import { describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | + | |
| 6 | +import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow"; | |
| 7 | +import { ContextWindowMeter } from "./ContextWindowMeter"; | |
| 8 | + | |
| 9 | +vi.mock("../ui/popover", () => ({ | |
| 10 | + Popover: ({ children }: { children: ReactNode }) => children, | |
| 11 | + PopoverPopup: ({ children }: { children: ReactNode }) => children, | |
| 12 | + PopoverTrigger: ({ closeDelay, render }: { closeDelay: number; render: ReactNode }) => ( | |
| 13 | + <div data-close-delay={closeDelay}>{render}</div> | |
| 14 | + ), | |
| 15 | +})); | |
| 16 | + | |
| 17 | +const usage = deriveLatestContextWindowSnapshot([ | |
| 18 | + { | |
| 19 | + id: EventId.make("activity-1"), | |
| 20 | + tone: "info", | |
| 21 | + kind: "context-window.updated", | |
| 22 | + summary: "Context updated", | |
| 23 | + payload: { usedTokens: 100_000, maxTokens: 1_000_000 }, | |
| 24 | + turnId: TurnId.make("turn-1"), | |
| 25 | + createdAt: "2026-08-24T12:00:00.000Z", | |
| 26 | + }, | |
| 27 | +]); | |
| 28 | + | |
| 29 | +if (!usage) { | |
| 30 | + throw new Error("The context window test fixture did not produce a snapshot."); | |
| 31 | +} | |
| 32 | + | |
| 33 | +describe("ContextWindowMeter", () => { | |
| 34 | + it("keeps the hover popover open while the pointer moves to the compact button", () => { | |
| 35 | + const markup = renderToStaticMarkup(<ContextWindowMeter usage={usage} onCompact={() => {}} />); | |
| 36 | + | |
| 37 | + expect(markup).toContain('data-close-delay="150"'); | |
| 38 | + expect(markup).toContain("Compact context"); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("closes an informational hover popover without delay", () => { | |
| 42 | + const markup = renderToStaticMarkup(<ContextWindowMeter usage={usage} />); | |
| 43 | + | |
| 44 | + expect(markup).toContain('data-close-delay="0"'); | |
| 45 | + expect(markup).not.toContain("Compact context"); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("explains why the compact action is disabled", () => { | |
| 49 | + const markup = renderToStaticMarkup( | |
| 50 | + <ContextWindowMeter | |
| 51 | + usage={usage} | |
| 52 | + onCompact={() => {}} | |
| 53 | + compactDisabled | |
| 54 | + compactDisabledReason="Send or clear your draft before compacting" | |
| 55 | + />, | |
| 56 | + ); | |
| 57 | + | |
| 58 | + expect(markup).toContain('disabled=""'); | |
| 59 | + expect(markup).toContain(">Send or clear your draft before compacting<"); | |
| 60 | + expect(markup).not.toContain('aria-label="Send or clear your draft before compacting"'); | |
| 61 | + }); | |
| 62 | +}); | |
| @@ -2,6 +2,7 @@ import { Button } from "../ui/button"; · 1 unmodified line | ||
| 2 | 2 | import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; |
| 3 | 3 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 4 | 4 | import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; |
| 5 | +import { Minimize2Icon } from "lucide-react"; | |
| 5 | 6 | |
| 6 | 7 | function formatPercentage(value: number | null): string | null { |
| 7 | 8 | if (value === null || !Number.isFinite(value)) { |
| @@ -16,8 +17,11 @@ function formatPercentage(value: number | null): string | null { · 8 unmodified lines | ||
| 16 | 17 | export function ContextWindowMeter(props: { |
| 17 | 18 | usage: ContextWindowSnapshot; |
| 18 | 19 | modelDisplayName?: string | null; |
| 20 | + onCompact?: (() => void) | undefined; | |
| 21 | + compactDisabled?: boolean | undefined; | |
| 22 | + compactDisabledReason?: string | null | undefined; | |
| 19 | 23 | }) { |
| 20 | − const { usage, modelDisplayName } = props; | |
| 24 | + const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props; | |
| 21 | 25 | const usedPercentage = formatPercentage(usage.usedPercentage); |
| 22 | 26 | const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); |
| 23 | 27 | const radius = 9.75; |
| @@ -35,7 +39,7 @@ export function ContextWindowMeter(props: { · 11 unmodified lines | ||
| 35 | 39 | <PopoverTrigger |
| 36 | 40 | openOnHover |
| 37 | 41 | delay={150} |
| 38 | − closeDelay={0} | |
| 42 | + closeDelay={onCompact ? 150 : 0} | |
| 39 | 43 | render={ |
| 40 | 44 | <Button |
| 41 | 45 | size="icon-sm" |
| @@ -128,9 +132,28 @@ export function ContextWindowMeter(props: { · 86 unmodified lines | ||
| 128 | 132 | ) : null} |
| 129 | 133 | {usage.compactsAutomatically ? ( |
| 130 | 134 | <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium"> |
| 131 | − {formatContextWindowCompactionMessage(modelDisplayName)} | |
| 135 | + {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)} | |
| 132 | 136 | </div> |
| 133 | 137 | ) : null} |
| 138 | + {onCompact ? ( | |
| 139 | + <> | |
| 140 | + <Button | |
| 141 | + size="xs" | |
| 142 | + variant="outline" | |
| 143 | + className="mt-1 w-full justify-center" | |
| 144 | + disabled={compactDisabled} | |
| 145 | + onClick={onCompact} | |
| 146 | + > | |
| 147 | + <Minimize2Icon aria-hidden="true" /> | |
| 148 | + Compact context | |
| 149 | + </Button> | |
| 150 | + {compactDisabled && compactDisabledReason ? ( | |
| 151 | + <div className="text-pretty text-secondary-label text-[11px]"> | |
| 152 | + {compactDisabledReason} | |
| 153 | + </div> | |
| 154 | + ) : null} | |
| 155 | + </> | |
| 156 | + ) : null} | |
| 134 | 157 | </div> |
| 135 | 158 | </PopoverPopup> |
| 136 | 159 | </Popover> |
| @@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => { · 36 unmodified lines | ||
| 37 | 37 | }); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | + it("shows the auto-compaction threshold for Claude providers", () => { | |
| 41 | + const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; | |
| 42 | + expect(claude).toBeDefined(); | |
| 43 | + | |
| 44 | + expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ | |
| 45 | + "binaryPath", | |
| 46 | + "homePath", | |
| 47 | + "autoCompactWindow", | |
| 48 | + "launchArgs", | |
| 49 | + ]); | |
| 50 | + }); | |
| 51 | + | |
| 40 | 52 | it("preserves unknown config keys while omitting empty configurable fields", () => { |
| 41 | 53 | const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; |
| 42 | 54 | expect(opencode).toBeDefined(); |
| @@ -26,6 +26,7 @@ describe("contextWindow", () => { · 25 unmodified lines | ||
| 26 | 26 | usedTokens: 14_000, |
| 27 | 27 | maxTokens: 258_000, |
| 28 | 28 | compactsAutomatically: true, |
| 29 | + autoCompactThreshold: 200_000, | |
| 29 | 30 | }), |
| 30 | 31 | ]); |
| 31 | 32 | |
| @@ -34,6 +35,7 @@ describe("contextWindow", () => { · 2 unmodified lines | ||
| 34 | 35 | expect(snapshot?.totalProcessedTokens).toBeNull(); |
| 35 | 36 | expect(snapshot?.maxTokens).toBe(258_000); |
| 36 | 37 | expect(snapshot?.compactsAutomatically).toBe(true); |
| 38 | + expect(snapshot?.autoCompactThreshold).toBe(200_000); | |
| 37 | 39 | }); |
| 38 | 40 | |
| 39 | 41 | it("ignores malformed payloads", () => { |
| @@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot( · 87 unmodified lines | ||
| 88 | 88 | toolUses: asFiniteNumber(payload?.toolUses), |
| 89 | 89 | durationMs: asFiniteNumber(payload?.durationMs), |
| 90 | 90 | compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, |
| 91 | + autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold), | |
| 91 | 92 | updatedAt: activity.createdAt, |
| 92 | 93 | }; |
| 93 | 94 | } |
| @@ -34,6 +34,18 @@ When you set this field, T3 Code points Claude Code at that directory with the · 33 unmodified lines | ||
| 34 | 34 | `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and |
| 35 | 35 | the rest of your environment stay as they are. |
| 36 | 36 | |
| 37 | +## Reduce Context Usage | |
| 38 | + | |
| 39 | +In Settings, open your Claude provider and set **Auto-compact after** to a token count between | |
| 40 | +`100000` and `1000000`. For example, `300000` compacts the conversation into a summary once it | |
| 41 | +reaches about 300,000 tokens, without changing the model's context window. Leave the field | |
| 42 | +empty to keep Claude Code's default behavior. | |
| 43 | + | |
| 44 | +On web and desktop, when you return to an older Claude thread with a large context, T3 Code | |
| 45 | +offers to compact the conversation before you continue. You can also select **Compact context** | |
| 46 | +from the context meter. On every client, you can enter `/compact` in the message composer, and | |
| 47 | +Claude can show its own resume prompt when you continue an old session. | |
| 48 | + | |
| 37 | 49 | ## Where Claude Skills Are Loaded |
| 38 | 50 | |
| 39 | 51 | T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then |
| @@ -324,6 +324,7 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({ · 323 unmodified lines | ||
| 324 | 324 | toolUses: Schema.optional(NonNegativeInt), |
| 325 | 325 | durationMs: Schema.optional(NonNegativeInt), |
| 326 | 326 | compactsAutomatically: Schema.optional(Schema.Boolean), |
| 327 | + autoCompactThreshold: Schema.optional(PositiveInt), | |
| 327 | 328 | }); |
| 328 | 329 | export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; |
| 329 | 330 | |
| @@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | ClientSettingsSchema, |
| 7 | 7 | ClientSettingsPatch, |
| 8 | + ClaudeSettings, | |
| 8 | 9 | DEFAULT_SERVER_SETTINGS, |
| 9 | 10 | defaultEnabledForDriver, |
| 10 | 11 | resolveProviderInstanceEnabled, |
| @@ -17,6 +18,36 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); · 6 unmodified lines | ||
| 17 | 18 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 18 | 19 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 19 | 20 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 21 | +const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); | |
| 22 | + | |
| 23 | +describe("ClaudeSettings auto-compaction", () => { | |
| 24 | + it("uses Claude's default threshold when no override is configured", () => { | |
| 25 | + expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it.each(["100000", "300000", "1000000"])( | |
| 29 | + "accepts a supported auto-compaction threshold: %s", | |
| 30 | + (value) => { | |
| 31 | + expect(decodeClaudeSettings({ autoCompactWindow: value }).autoCompactWindow).toBe(value); | |
| 32 | + }, | |
| 33 | + ); | |
| 34 | + | |
| 35 | + it.each(["99999", "1000001", "300k", "invalid"])( | |
| 36 | + "rejects an unsupported auto-compaction threshold: %s", | |
| 37 | + (value) => { | |
| 38 | + expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); | |
| 39 | + }, | |
| 40 | + ); | |
| 41 | + | |
| 42 | + it("rejects an unsupported threshold at the settings patch boundary", () => { | |
| 43 | + expect(() => | |
| 44 | + decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }), | |
| 45 | + ).toThrow(); | |
| 46 | + expect( | |
| 47 | + decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }), | |
| 48 | + ).toBeDefined(); | |
| 49 | + }); | |
| 50 | +}); | |
| 20 | 51 | |
| 21 | 52 | describe("ClientSettings word wrap", () => { |
| 22 | 53 | it("defaults word wrap on", () => { |
| @@ -373,6 +373,11 @@ export const CodexSettings = makeProviderSettingsSchema( · 372 unmodified lines | ||
| 373 | 373 | ); |
| 374 | 374 | export type CodexSettings = typeof CodexSettings.Type; |
| 375 | 375 | |
| 376 | +// Empty, or an integer from 100,000 to 1,000,000. Shared by the full | |
| 377 | +// Claude settings schema and its patch so an out-of-range value fails at | |
| 378 | +// the update that introduced it. | |
| 379 | +const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/; | |
| 380 | + | |
| 376 | 381 | export const ClaudeSettings = makeProviderSettingsSchema( |
| 377 | 382 | { |
| 378 | 383 | enabled: Schema.Boolean.pipe( |
| @@ -410,9 +415,23 @@ export const ClaudeSettings = makeProviderSettingsSchema( · 31 unmodified lines | ||
| 410 | 415 | }, |
| 411 | 416 | }), |
| 412 | 417 | ), |
| 418 | + autoCompactWindow: TrimmedString.check( | |
| 419 | + Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN), | |
| 420 | + ).pipe( | |
| 421 | + Schema.withDecodingDefault(Effect.succeed("")), | |
| 422 | + Schema.annotateKey({ | |
| 423 | + title: "Auto-compact after", | |
| 424 | + description: | |
| 425 | + "Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.", | |
| 426 | + providerSettingsForm: { | |
| 427 | + placeholder: "e.g. 300000", | |
| 428 | + clearWhenEmpty: "omit", | |
| 429 | + }, | |
| 430 | + }), | |
| 431 | + ), | |
| 413 | 432 | }, |
| 414 | 433 | { |
| 415 | − order: ["binaryPath", "homePath", "launchArgs"], | |
| 434 | + order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"], | |
| 416 | 435 | }, |
| 417 | 436 | ); |
| 418 | 437 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| @@ -797,6 +816,11 @@ const ClaudeSettingsPatch = Schema.Struct({ · 378 unmodified lines | ||
| 797 | 816 | homePath: Schema.optionalKey(TrimmedString), |
| 798 | 817 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 799 | 818 | launchArgs: Schema.optionalKey(TrimmedString), |
| 819 | + // Validated at the patch boundary so a typo fails the one update with a | |
| 820 | + // schema error instead of a generic whole-settings failure. | |
| 821 | + autoCompactWindow: Schema.optionalKey( | |
| 822 | + TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)), | |
| 823 | + ), | |
| 800 | 824 | }); |
| 801 | 825 | |
| 802 | 826 | const CursorSettingsPatch = Schema.Struct({ |
| @@ -226,6 +226,10 @@ · 225 unmodified lines | ||
| 226 | 226 | "./usageFormat": { |
| 227 | 227 | "types": "./src/usageFormat.ts", |
| 228 | 228 | "import": "./src/usageFormat.ts" |
| 229 | + }, | |
| 230 | + "./claudeCompaction": { | |
| 231 | + "types": "./src/claudeCompaction.ts", | |
| 232 | + "import": "./src/claudeCompaction.ts" | |
| 229 | 233 | } |
| 230 | 234 | }, |
| 231 | 235 | "scripts": { |
| @@ -0,0 +1,35 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + formatClaudeResumeCompactionQuestion, | |
| 5 | + isClaudeResumeCompactionQuestion, | |
| 6 | +} from "./claudeCompaction.ts"; | |
| 7 | + | |
| 8 | +describe("claude resume compaction copy", () => { | |
| 9 | + // The matcher must recognize every question the formatter can produce. | |
| 10 | + // This is the drift guard: rewording one side fails here. | |
| 11 | + it.each([ | |
| 12 | + { ageMinutes: 145, estimatedTokens: 275_123 }, | |
| 13 | + { ageMinutes: 70, estimatedTokens: 100_000 }, | |
| 14 | + { ageMinutes: 59, estimatedTokens: 1_234_567 }, | |
| 15 | + { ageMinutes: 0, estimatedTokens: 0 }, | |
| 16 | + ])("matches its own formatted question (%o)", (input) => { | |
| 17 | + const question = formatClaudeResumeCompactionQuestion(input); | |
| 18 | + expect(isClaudeResumeCompactionQuestion(question)).toBe(true); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("formats ages above and below one hour", () => { | |
| 22 | + expect( | |
| 23 | + formatClaudeResumeCompactionQuestion({ ageMinutes: 145, estimatedTokens: 275_123 }), | |
| 24 | + ).toBe("This session is 2h 25m old and uses 275,123 tokens. Compact it before continuing?"); | |
| 25 | + expect(formatClaudeResumeCompactionQuestion({ ageMinutes: 45, estimatedTokens: 1_000 })).toBe( | |
| 26 | + "This session is 45m old and uses 1,000 tokens. Compact it before continuing?", | |
| 27 | + ); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("does not match unrelated questions", () => { | |
| 31 | + expect( | |
| 32 | + isClaudeResumeCompactionQuestion("The build cache is large. Compact it before continuing?"), | |
| 33 | + ).toBe(false); | |
| 34 | + }); | |
| 35 | +}); | |
| @@ -0,0 +1,26 @@ | ||
| 1 | +/** | |
| 2 | + * Copy for Claude's resume compaction dialog, shared by the server adapter | |
| 3 | + * (which asks the question) and the web client (which recognizes the | |
| 4 | + * question and its "never" answer in resolved user-input activities to | |
| 5 | + * mirror the dismissal). Both sides must agree on these strings, so they | |
| 6 | + * live here: reword the question or the answer label in this file only. | |
| 7 | + */ | |
| 8 | +export const CLAUDE_RESUME_COMPACTION_NEVER_ANSWER = "Don't ask again"; | |
| 9 | + | |
| 10 | +export function formatClaudeResumeCompactionQuestion(input: { | |
| 11 | + readonly ageMinutes: number; | |
| 12 | + readonly estimatedTokens: number; | |
| 13 | +}): string { | |
| 14 | + const ageLabel = | |
| 15 | + input.ageMinutes >= 60 | |
| 16 | + ? `${Math.floor(input.ageMinutes / 60)}h ${input.ageMinutes % 60}m` | |
| 17 | + : `${input.ageMinutes}m`; | |
| 18 | + return `This session is ${ageLabel} old and uses ${input.estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN = | |
| 22 | + /^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$/u; | |
| 23 | + | |
| 24 | +export function isClaudeResumeCompactionQuestion(question: string): boolean { | |
| 25 | + return CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN.test(question); | |
| 26 | +} | |
| @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line | ||
| 2 | 2 | import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + applyClaudePromptEffortPrefix, | |
| 5 | 6 | buildProviderOptionSelectionsFromDescriptors, |
| 6 | 7 | createModelCapabilities, |
| 7 | 8 | createModelSelection, |
| @@ -155,3 +156,33 @@ describe("model slug normalization", () => { · 147 unmodified lines | ||
| 155 | 156 | expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); |
| 156 | 157 | }); |
| 157 | 158 | }); |
| 159 | + | |
| 160 | +describe("applyClaudePromptEffortPrefix", () => { | |
| 161 | + it("keeps slash commands intact when ultrathink is selected", () => { | |
| 162 | + expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); | |
| 163 | + expect(applyClaudePromptEffortPrefix(" /compact keep recent errors ", "ultrathink")).toBe( | |
| 164 | + "/compact keep recent errors", | |
| 165 | + ); | |
| 166 | + expect(applyClaudePromptEffortPrefix(" /review src/model.ts ", "ultrathink")).toBe( | |
| 167 | + "/review src/model.ts", | |
| 168 | + ); | |
| 169 | + expect(applyClaudePromptEffortPrefix("/security-review", "ultrathink")).toBe( | |
| 170 | + "/security-review", | |
| 171 | + ); | |
| 172 | + expect(applyClaudePromptEffortPrefix("/plugin:skill run", "ultrathink")).toBe( | |
| 173 | + "/plugin:skill run", | |
| 174 | + ); | |
| 175 | + expect(applyClaudePromptEffortPrefix("/deploy.prod to staging", "ultrathink")).toBe( | |
| 176 | + "/deploy.prod to staging", | |
| 177 | + ); | |
| 178 | + }); | |
| 179 | + | |
| 180 | + it("still adds the ultrathink prefix to ordinary prompts", () => { | |
| 181 | + expect(applyClaudePromptEffortPrefix("Investigate this failure", "ultrathink")).toBe( | |
| 182 | + "Ultrathink:\nInvestigate this failure", | |
| 183 | + ); | |
| 184 | + expect(applyClaudePromptEffortPrefix("/home/theo/app.ts crashed on load", "ultrathink")).toBe( | |
| 185 | + "Ultrathink:\n/home/theo/app.ts crashed on load", | |
| 186 | + ); | |
| 187 | + }); | |
| 188 | +}); | |
| @@ -362,7 +362,11 @@ export function applyClaudePromptEffortPrefix( · 361 unmodified lines | ||
| 362 | 362 | if (!trimmed) { |
| 363 | 363 | return trimmed; |
| 364 | 364 | } |
| 365 | − if (effort !== "ultrathink") { | |
| 365 | + // Prefixing a slash command turns it into plain prose, so Claude never | |
| 366 | + // runs it. Command names come from arbitrary file names ("/deploy.prod", | |
| 367 | + // "/plugin:skill"), so accept any first token without a second slash; | |
| 368 | + // absolute paths like "/home/theo/app.ts" keep the prefix. | |
| 369 | + if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) { | |
| 366 | 370 | return trimmed; |
| 367 | 371 | } |
| 368 | 372 | if (trimmed.startsWith("Ultrathink:")) { |