From e693d279b3c240e288c1f10943781b7c9a2a7581 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 01:09:29 -0700 Subject: [PATCH] fix: Bedrock ARN region routing, Copilot device-flow pacing, TUI input fixes (#109740) * build(deps): bump terminal UI library to 0.80.9 * fix(plugins): Bedrock ARN region routing, stop-reason detail, and Copilot device-flow pacing --- .../amazon-bedrock/stream.runtime.test.ts | 94 +++++++++++++- extensions/amazon-bedrock/stream.runtime.ts | 42 +++++-- extensions/github-copilot/login.test.ts | 118 +++++++++++++++++- extensions/github-copilot/login.ts | 28 ++++- npm-shrinkwrap.json | 8 +- package.json | 2 +- pnpm-lock.yaml | 10 +- src/tui/components/hyperlink-markdown.ts | 12 +- src/tui/components/markdown-message.ts | 12 +- src/tui/components/user-message.test.ts | 18 +++ src/tui/components/user-message.ts | 16 ++- 11 files changed, 314 insertions(+), 46 deletions(-) create mode 100644 src/tui/components/user-message.test.ts diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index 27881194710c..ecfdad442359 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -1,5 +1,9 @@ // Amazon Bedrock tests cover stream plugin behavior. -import { BedrockRuntimeClient, ConversationRole } from "@aws-sdk/client-bedrock-runtime"; +import { + BedrockRuntimeClient, + ConversationRole, + StopReason as BedrockStopReason, +} from "@aws-sdk/client-bedrock-runtime"; import { onLlmRequestActivity } from "openclaw/plugin-sdk/provider-stream-shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js"; @@ -48,7 +52,30 @@ async function* streamEvents(events: unknown[]) { } } +async function captureClientRegion( + model: Parameters[0], + options: Parameters[2] = {}, +): Promise { + const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: streamEvents([ + { messageStart: { role: ConversationRole.ASSISTANT } }, + { messageStop: { stopReason: BedrockStopReason.END_TURN } }, + ]), + } as never); + + await streamBedrock( + model, + { messages: [{ role: "user", content: "Hello", timestamp: 0 }] } as never, + options, + ).result(); + + const client = send.mock.contexts[0] as BedrockRuntimeClient; + return client.config.region(); +} + afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -203,6 +230,71 @@ describe("Bedrock profile endpoint resolution", () => { ), ).toBe(false); }); + + it.each([ + { + name: "plain model id", + modelId: "amazon.nova-micro-v1:0", + ambientRegion: "eu-west-1", + expectedRegion: "eu-west-1", + }, + { + name: "application inference-profile ARN", + modelId: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/profile-abc", + ambientRegion: "us-east-1", + expectedRegion: "us-west-2", + }, + { + name: "GovCloud inference-profile ARN", + modelId: + "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/profile-abc", + ambientRegion: "us-east-1", + expectedRegion: "us-gov-west-1", + }, + { + name: "ARN with explicit region option", + modelId: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/profile-abc", + ambientRegion: "us-east-1", + explicitRegion: "ap-southeast-2", + expectedRegion: "ap-southeast-2", + }, + ])( + "resolves $name to $expectedRegion", + async ({ modelId, ambientRegion, explicitRegion, expectedRegion }) => { + vi.stubEnv("AWS_REGION", ambientRegion); + + await expect( + captureClientRegion( + bedrockModel({ id: modelId }), + explicitRegion ? { region: explicitRegion } : {}, + ), + ).resolves.toBe(expectedRegion); + }, + ); +}); + +describe("Bedrock stop reasons", () => { + it.each([ + BedrockStopReason.CONTENT_FILTERED, + BedrockStopReason.GUARDRAIL_INTERVENED, + BedrockStopReason.MALFORMED_MODEL_OUTPUT, + BedrockStopReason.MALFORMED_TOOL_USE, + ])("reports the provider stop reason %s", async (stopReason) => { + vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: streamEvents([ + { messageStart: { role: ConversationRole.ASSISTANT } }, + { messageStop: { stopReason } }, + ]), + } as never); + + const result = await streamBedrock(bedrockModel({}), { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], + } as never).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe(stopReason); + }); }); describe("Bedrock thinking effort mapping", () => { diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index 2fbd75a97cb5..55caf747d62a 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -166,11 +166,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt profile: options.profile, }; const configuredRegion = getConfiguredBedrockRegion(options); + const requestRegion = options.region || getBedrockModelArnRegion(model.id) || configuredRegion; const hasConfiguredProfile = hasConfiguredBedrockProfile(options); const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl); const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint( model.baseUrl, - configuredRegion, + requestRegion, hasConfiguredProfile, ); @@ -187,11 +188,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt // in Node.js/Bun environment only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - // Region resolution: explicit option > env vars > SDK default chain. - // When AWS_PROFILE is set, we leave region undefined so the SDK can - // resovle it from aws profile configs. Otherwise fall back to us-east-1. - if (configuredRegion) { - config.region = configuredRegion; + // Region resolution: explicit option > model ARN > env vars > SDK default chain. + // When AWS_PROFILE is set, leave region undefined so the SDK can resolve it. + if (requestRegion) { + config.region = requestRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; } else if (!hasConfiguredProfile) { @@ -220,7 +220,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt // Non-Node environment (browser): fall back to us-east-1 since // there's no config file resolution available. config.region = - configuredRegion || + requestRegion || (endpointRegion && useExplicitEndpoint ? endpointRegion : undefined) || "us-east-1"; } @@ -303,7 +303,11 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt model.provider, ); } else { - output.stopReason = mapStopReason(item.messageStop.stopReason); + const mappedStop = mapStopReason(item.messageStop.stopReason); + output.stopReason = mappedStop.stopReason; + if (mappedStop.errorMessage) { + output.errorMessage = mappedStop.errorMessage; + } } } else if (item.metadata) { handleMetadata(item.metadata, model, output); @@ -1002,21 +1006,33 @@ function convertToolConfig( return { tools: bedrockTools, toolChoice: bedrockToolChoice }; } -function mapStopReason(reason: string | undefined): StopReason { +function mapStopReason(reason: string | undefined): { + stopReason: StopReason; + errorMessage?: string; +} { switch (reason) { case BedrockStopReason.END_TURN: case BedrockStopReason.STOP_SEQUENCE: - return "stop"; + return { stopReason: "stop" }; case BedrockStopReason.MAX_TOKENS: case BedrockStopReason.MODEL_CONTEXT_WINDOW_EXCEEDED: - return "length"; + return { stopReason: "length" }; case BedrockStopReason.TOOL_USE: - return "toolUse"; + return { stopReason: "toolUse" }; + case BedrockStopReason.CONTENT_FILTERED: + case BedrockStopReason.GUARDRAIL_INTERVENED: + case BedrockStopReason.MALFORMED_MODEL_OUTPUT: + case BedrockStopReason.MALFORMED_TOOL_USE: + return { stopReason: "error", errorMessage: reason }; default: - return "error"; + return reason ? { stopReason: "error", errorMessage: reason } : { stopReason: "error" }; } } +function getBedrockModelArnRegion(modelId: string): string | undefined { + return /^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/.exec(modelId)?.[1]; +} + function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined { if (typeof process === "undefined") { return options.region; diff --git a/extensions/github-copilot/login.test.ts b/extensions/github-copilot/login.test.ts index ae5588d7663f..e5d0ee4a5f81 100644 --- a/extensions/github-copilot/login.test.ts +++ b/extensions/github-copilot/login.test.ts @@ -40,8 +40,20 @@ function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) { afterEach(() => { mocks.fetchWithSsrFGuard.mockReset(); vi.restoreAllMocks(); + vi.useRealTimers(); }); +function runDeviceFlowAfterFirstPoll( + io: Parameters[0], + domain?: string, +) { + vi.useFakeTimers(); + const result = runGitHubCopilotDeviceFlow(io, domain); + return Promise.all([result, vi.advanceTimersByTimeAsync(5_000)]).then( + ([flowResult]) => flowResult, + ); +} + describe("runGitHubCopilotDeviceFlow — normal flow", () => { it("bounds requests and returns authorized status and access token on successful flow", async () => { let callIdx = 0; @@ -64,7 +76,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => { }); const showCode = vi.fn(async () => {}); - const result = await runGitHubCopilotDeviceFlow({ showCode, signal: controller.signal }); + const result = await runDeviceFlowAfterFirstPoll({ showCode, signal: controller.signal }); expect(result).toEqual({ status: "authorized", accessToken: "ghu_tok_xyz" }); expect(showCode).toHaveBeenCalledWith({ @@ -86,7 +98,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => { return guardResponse({ error: "access_denied" }, 200, ACCESS_TOKEN_URL); }); - const result = await runGitHubCopilotDeviceFlow({ + const result = await runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}), }); expect(result).toEqual({ status: "access_denied" }); @@ -102,7 +114,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => { return guardResponse({ error: "expired_token" }, 200, ACCESS_TOKEN_URL); }); - const result = await runGitHubCopilotDeviceFlow({ + const result = await runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}), }); expect(result).toEqual({ status: "expired" }); @@ -128,10 +140,25 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => { return guardResponse({}, 500, ACCESS_TOKEN_URL); }); - await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow( + await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow( "GitHub device token failed: HTTP 500", ); }); + + it("rejects a malformed access token response", async () => { + let callIdx = 0; + mocks.fetchWithSsrFGuard.mockImplementation(async () => { + callIdx += 1; + if (callIdx === 1) { + return guardResponse(VALID_DEVICE_CODE_BODY); + } + return guardResponse({ access_token: null, token_type: "bearer" }, 200, ACCESS_TOKEN_URL); + }); + + await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow( + "GitHub device flow returned an invalid access token", + ); + }); }); describe("postGitHubDeviceFlowForm — response size bound", () => { @@ -208,7 +235,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => { }; }); - await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow( + await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow( "github-copilot.device-flow", ); @@ -246,7 +273,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () = }); const showCode = vi.fn(async () => {}); - const result = await runGitHubCopilotDeviceFlow({ showCode }, GHE_DOMAIN); + const result = await runDeviceFlowAfterFirstPoll({ showCode }, GHE_DOMAIN); expect(result).toEqual({ status: "authorized", accessToken: "ghu_ghe_tok" }); expect(urls).toEqual([gheDeviceCodeUrl, gheAccessTokenUrl]); @@ -271,3 +298,82 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () = ).rejects.toThrow("unexpected verification URL"); }); }); + +describe("runGitHubCopilotDeviceFlow — polling intervals", () => { + it("waits before the first poll and keeps cumulative slow_down increases", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-16T00:00:00Z")); + const startedAt = Date.now(); + const pollTimes: number[] = []; + const pollResponses = [ + { error: "authorization_pending" }, + { error: "slow_down" }, + { error: "slow_down" }, + { access_token: "test-access-token", token_type: "bearer" }, + ]; + mocks.fetchWithSsrFGuard.mockImplementation(async (params) => { + if (params.url === DEVICE_CODE_URL) { + const { interval: _interval, ...withoutInterval } = VALID_DEVICE_CODE_BODY; + return guardResponse(withoutInterval); + } + pollTimes.push(Date.now()); + return guardResponse(pollResponses.shift(), 200, ACCESS_TOKEN_URL); + }); + + const result = runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) }); + + await vi.advanceTimersByTimeAsync(4_999); + expect(pollTimes).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(pollTimes).toEqual([startedAt + 5_000]); + + await vi.advanceTimersByTimeAsync(5_000); + expect(pollTimes).toEqual([startedAt + 5_000, startedAt + 10_000]); + await vi.advanceTimersByTimeAsync(10_000); + expect(pollTimes).toEqual([startedAt + 5_000, startedAt + 10_000, startedAt + 20_000]); + await vi.advanceTimersByTimeAsync(15_000); + + await expect(result).resolves.toEqual({ + status: "authorized", + accessToken: "test-access-token", + }); + expect(pollTimes).toEqual([ + startedAt + 5_000, + startedAt + 10_000, + startedAt + 20_000, + startedAt + 35_000, + ]); + }); + + it("uses the interval returned with slow_down", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-16T00:00:00Z")); + const startedAt = Date.now(); + const pollTimes: number[] = []; + const pollResponses = [ + { error: "slow_down", interval: 7 }, + { access_token: "test-access-token", token_type: "bearer" }, + ]; + mocks.fetchWithSsrFGuard.mockImplementation(async (params) => { + if (params.url === DEVICE_CODE_URL) { + return guardResponse({ ...VALID_DEVICE_CODE_BODY, interval: 2 }); + } + pollTimes.push(Date.now()); + return guardResponse(pollResponses.shift(), 200, ACCESS_TOKEN_URL); + }); + + const result = runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) }); + + await vi.advanceTimersByTimeAsync(2_000); + expect(pollTimes).toEqual([startedAt + 2_000]); + await vi.advanceTimersByTimeAsync(6_999); + expect(pollTimes).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + + await expect(result).resolves.toEqual({ + status: "authorized", + accessToken: "test-access-token", + }); + expect(pollTimes).toEqual([startedAt + 2_000, startedAt + 9_000]); + }); +}); diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 00bc291b4f5b..82e68653c44c 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -25,6 +25,8 @@ import { const CLIENT_ID = "Iv1.b507a08c87ecfe98"; const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000; +const GITHUB_DEVICE_FLOW_DEFAULT_INTERVAL_MS = 5_000; +const GITHUB_DEVICE_FLOW_SLOW_DOWN_INCREMENT_MS = 5_000; // Data-residency GitHub Enterprise support: the device flow, token exchange, and // completions endpoints all live under the tenant host (e.g. "acme.ghe.com") // instead of github.com. The host is threaded in from the selected auth flow so @@ -55,6 +57,7 @@ type DeviceTokenResponse = error: string; error_description?: string; error_uri?: string; + interval?: unknown; }; const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied"); @@ -100,7 +103,10 @@ function parseDeviceCodeResponse( issuedAt: number, ): DeviceCodeResponse { const expiresInMs = positiveSecondsToSafeMilliseconds(value.expires_in); - const intervalMs = nonNegativeSecondsToSafeMilliseconds(value.interval); + const intervalMs = + value.interval === undefined + ? GITHUB_DEVICE_FLOW_DEFAULT_INTERVAL_MS + : nonNegativeSecondsToSafeMilliseconds(value.interval); const expiresAt = expiresInMs === undefined ? undefined @@ -199,7 +205,13 @@ async function pollForAccessToken(params: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", }); + let intervalMs = params.intervalMs; while (Date.now() < params.expiresAt) { + await sleepGitHubDevicePollDelay(intervalMs, params.expiresAt, params.signal); + if (Date.now() >= params.expiresAt) { + break; + } + const json = (await postGitHubDeviceFlowForm({ url: accessTokenUrl(params.domain), body: bodyBase, @@ -207,17 +219,21 @@ async function pollForAccessToken(params: { domain: params.domain, ...(params.signal ? { signal: params.signal } : {}), })) as DeviceTokenResponse; - if ("access_token" in json && typeof json.access_token === "string") { - return json.access_token; + if ("access_token" in json) { + if (typeof json.access_token === "string") { + return json.access_token; + } + throw new Error("GitHub device flow returned an invalid access token"); } - const err = "error" in json ? json.error : "unknown"; + const err = json.error; if (err === "authorization_pending") { - await sleepGitHubDevicePollDelay(params.intervalMs, params.expiresAt, params.signal); continue; } if (err === "slow_down") { - await sleepGitHubDevicePollDelay(params.intervalMs + 2000, params.expiresAt, params.signal); + intervalMs = + positiveSecondsToSafeMilliseconds(json.interval) ?? + Math.min(Number.MAX_SAFE_INTEGER, intervalMs + GITHUB_DEVICE_FLOW_SLOW_DOWN_INCREMENT_MS); continue; } if (err === "expired_token") { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 3d63a62635cc..60c14a3fd761 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,7 +14,7 @@ "@anthropic-ai/sdk": "0.109.1", "@clack/core": "1.4.2", "@clack/prompts": "1.6.0", - "@earendil-works/pi-tui": "0.80.3", + "@earendil-works/pi-tui": "0.80.9", "@google/genai": "2.10.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", @@ -161,9 +161,9 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", - "integrity": "sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==", + "version": "0.80.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.9.tgz", + "integrity": "sha512-unPTW8hRgIHEGjV8mJJ2jqm+fzgnRubes6V2FPk9ay1W9ZLofcpYQ3NDfrODXSci+oKbBpX9JyYUMfQV6jCA/A==", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/package.json b/package.json index cf9fba4f9080..ed8f3cae35eb 100644 --- a/package.json +++ b/package.json @@ -2025,7 +2025,7 @@ "@anthropic-ai/sdk": "0.109.1", "@clack/core": "1.4.2", "@clack/prompts": "1.6.0", - "@earendil-works/pi-tui": "0.80.3", + "@earendil-works/pi-tui": "0.80.9", "@google/genai": "2.10.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ebc28d867d7..0c160743cd3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,8 +56,8 @@ importers: specifier: 1.6.0 version: 1.6.0 '@earendil-works/pi-tui': - specifier: 0.80.3 - version: 0.80.3 + specifier: 0.80.9 + version: 0.80.9 '@google/genai': specifier: 2.10.0 version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) @@ -2871,8 +2871,8 @@ packages: resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==} engines: {node: '>=22.12.0'} - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-tui@0.80.9': + resolution: {integrity: sha512-unPTW8hRgIHEGjV8mJJ2jqm+fzgnRubes6V2FPk9ay1W9ZLofcpYQ3NDfrODXSci+oKbBpX9JyYUMfQV6jCA/A==} engines: {node: '>=22.19.0'} '@emnapi/core@1.11.1': @@ -9311,7 +9311,7 @@ snapshots: - opusscript - utf-8-validate - '@earendil-works/pi-tui@0.80.3': + '@earendil-works/pi-tui@0.80.9': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 diff --git a/src/tui/components/hyperlink-markdown.ts b/src/tui/components/hyperlink-markdown.ts index 662964569370..34fd73a6061d 100644 --- a/src/tui/components/hyperlink-markdown.ts +++ b/src/tui/components/hyperlink-markdown.ts @@ -1,5 +1,10 @@ // Hyperlink markdown helpers render markdown links with TUI hyperlink styling. -import type { Component, DefaultTextStyle, MarkdownTheme } from "@earendil-works/pi-tui"; +import type { + Component, + DefaultTextStyle, + MarkdownOptions, + MarkdownTheme, +} from "@earendil-works/pi-tui"; import { Markdown } from "@earendil-works/pi-tui"; import { addOsc8Hyperlinks, extractUrls } from "../osc8-hyperlinks.js"; @@ -17,9 +22,10 @@ export class HyperlinkMarkdown implements Component { paddingX: number, paddingY: number, theme: MarkdownTheme, - options?: DefaultTextStyle, + defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, ) { - this.inner = new Markdown(text, paddingX, paddingY, theme, options); + this.inner = new Markdown(text, paddingX, paddingY, theme, defaultTextStyle, options); this.urls = extractUrls(text); } diff --git a/src/tui/components/markdown-message.ts b/src/tui/components/markdown-message.ts index bbe7403acbd8..aeb42f996f2c 100644 --- a/src/tui/components/markdown-message.ts +++ b/src/tui/components/markdown-message.ts @@ -4,15 +4,21 @@ import { markdownTheme } from "../theme/theme.js"; import { HyperlinkMarkdown } from "./hyperlink-markdown.js"; // Shared markdown message wrapper with a leading spacer for chat-log rows. -type MarkdownOptions = ConstructorParameters[4]; +type DefaultTextStyle = ConstructorParameters[4]; +type MarkdownOptions = ConstructorParameters[5]; /** Container-backed markdown message that can update text in place. */ export class MarkdownMessageComponent extends Container { private body: HyperlinkMarkdown; - constructor(text: string, y: number, options?: MarkdownOptions) { + constructor( + text: string, + y: number, + defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, + ) { super(); - this.body = new HyperlinkMarkdown(text, 0, y, markdownTheme, options); + this.body = new HyperlinkMarkdown(text, 0, y, markdownTheme, defaultTextStyle, options); this.addChild(new Spacer(1)); this.addChild(this.body); } diff --git a/src/tui/components/user-message.test.ts b/src/tui/components/user-message.test.ts new file mode 100644 index 000000000000..021d766fe3c3 --- /dev/null +++ b/src/tui/components/user-message.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; +import { UserMessageComponent } from "./user-message.js"; + +describe("UserMessageComponent", () => { + it("preserves ordered-list markers and backslash escapes", () => { + const message = new UserMessageComponent(String.raw`7. first +9. second + +Escaped \*literal\*`); + + const rendered = message.render(80).map(normalizeTestText).join("\n"); + + expect(rendered).toContain("7. first"); + expect(rendered).toContain("9. second"); + expect(rendered).toContain(String.raw`\*literal\*`); + }); +}); diff --git a/src/tui/components/user-message.ts b/src/tui/components/user-message.ts index 0e1db88855c6..a48a0f55b91b 100644 --- a/src/tui/components/user-message.ts +++ b/src/tui/components/user-message.ts @@ -5,9 +5,17 @@ import { MarkdownMessageComponent } from "./markdown-message.js"; /** Markdown chat-log row styled as user input. */ export class UserMessageComponent extends MarkdownMessageComponent { constructor(text: string) { - super(text, 1, { - bgColor: (line) => theme.userBg(line), - color: (line) => theme.userText(line), - }); + super( + text, + 1, + { + bgColor: (line) => theme.userBg(line), + color: (line) => theme.userText(line), + }, + { + preserveOrderedListMarkers: true, + preserveBackslashEscapes: true, + }, + ); } }