Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 9144e6d01a fix(tui): preserve legacy option enter newlines 2026-08-07 21:16:06 +00:00
opencode-agent[bot] 0b84e24e65 fix(tui): standardize compact terminology (#41141) 2026-08-07 16:26:14 -04:00
17 changed files with 125 additions and 152 deletions
@@ -4,7 +4,12 @@ import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth"
import { ProviderShared } from "../shared"
/** AWS credentials for SigV4 signing. */
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
*/
export interface Credentials {
readonly region: string
readonly accessKeyId: string
@@ -12,8 +17,6 @@ export interface Credentials {
readonly sessionToken?: string
}
export type CredentialProvider = () => Promise<Credentials>
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -45,7 +48,7 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | CredentialProvider | undefined,
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
@@ -55,22 +58,12 @@ export const sigV4 = (
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
)
}
const resolved =
typeof credentials === "function"
? yield* Effect.tryPromise({
try: credentials,
catch: (error) =>
ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} credential resolution failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
: credentials
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
body: input.body,
headers: headersForSigning,
credentials: resolved,
credentials,
service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse",
})
+3 -4
View File
@@ -4,14 +4,13 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import * as BedrockConverse from "../protocols/bedrock-converse"
import type { BedrockCredentials } from "../protocols/bedrock-converse"
import type { CredentialProvider } from "../protocols/utils/bedrock-auth"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
readonly apiKey?: string
readonly headers?: Record<string, string>
readonly credentials?: BedrockCredentials | CredentialProvider
readonly credentials?: BedrockCredentials
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
@@ -22,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials | CredentialProvider
readonly credentials?: BedrockCredentials
readonly region?: string
readonly topP?: number
}
@@ -32,7 +31,7 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? (typeof credentials === "function" ? undefined : credentials?.region) ?? "us-east-1"
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return BedrockConverse.route.with({
...rest,
provider: id,
@@ -519,8 +519,14 @@ describe("Bedrock Converse route", () => {
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }],
),
),
@@ -555,7 +561,10 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
@@ -751,26 +760,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("resolves fresh SigV4 credentials for each request", () => {
let calls = 0
const signed = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
credentials: async () => {
calls++
return {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
return LLMClient.generate(LLMRequest.update(baseRequest, { model: signed })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.tap(() => Effect.sync(() => expect(calls).toBe(1))),
)
})
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
+1 -3
View File
@@ -88,9 +88,7 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials =
mapBedrockCredentials(settings) ??
(typeof settings.credentialProvider === "function" ? settings.credentialProvider : undefined)
const credentials = mapBedrockCredentials(settings)
return {
...baseSettings,
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
@@ -59,40 +59,18 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
return sdk.responses(modelID)
}
function defaultCredentialProvider(profile: string | undefined, region: string) {
const load = import("@aws-sdk/credential-providers").then((mod) =>
mod.fromNodeProviderChain(profile ? { profile } : {}),
)
return async () => ({ ...(await (await load)()), region })
}
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (
!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(
Provider.packageName(item.provider.package) ?? "",
)
)
continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
const settings = provider.settings ?? {}
const profile = typeof settings.profile === "string" ? settings.profile : process.env.AWS_PROFILE
const region = typeof settings.region === "string" ? settings.region : (process.env.AWS_REGION ?? "us-east-1")
provider.settings = {
...settings,
region,
...(typeof settings.credentialProvider === "function"
? {}
: { credentialProvider: defaultCredentialProvider(profile, region) }),
}
if (typeof settings.endpoint !== "string") return
if (typeof provider.settings?.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.settings.baseURL = settings.endpoint
provider.settings.baseURL = provider.settings.endpoint
delete provider.settings.endpoint
})
}
@@ -117,7 +95,8 @@ export const AmazonBedrockPlugin = define({
if (!bearerToken && options.credentialProvider === undefined) {
// Do not gate SDK creation on explicit AWS env vars. The default chain
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
options.credentialProvider = defaultCredentialProvider(profile, region)
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
}
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
-12
View File
@@ -158,18 +158,6 @@ describe("AISDKNative", () => {
})
})
test("maps a Bedrock credential provider to the native runtime", () => {
const credentialProvider = async () => ({
region: "us-east-1",
accessKeyId: "key",
secretAccessKey: "secret",
})
for (const packageName of ["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"]) {
expect(map(packageName, { credentialProvider })?.settings).toMatchObject({ credentials: credentialProvider })
}
})
test("maps the legacy Bedrock endpoint override", () => {
expect(
map(
@@ -80,66 +80,24 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint setting to baseURL", () =>
withEnv({ AWS_PROFILE: undefined, AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { endpoint: "https://bedrock.example" },
})
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
item.settings = { endpoint: "https://bedrock.example" }
})
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { endpoint: "https://bedrock.example" },
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toMatchObject({
baseURL: "https://bedrock.example",
region: "us-east-1",
credentialProvider: expect.any(Function),
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
item.settings = { endpoint: "https://bedrock.example" }
})
}),
),
)
it.effect("discovers AWS credentials for the native Bedrock runtime", () =>
withEnv(
{
AWS_ACCESS_KEY_ID: "key",
AWS_SECRET_ACCESS_KEY: "secret",
AWS_SESSION_TOKEN: "session",
AWS_PROFILE: undefined,
AWS_REGION: "eu-west-1",
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
})
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
const credentialProvider = result.settings?.credentialProvider
if (typeof credentialProvider !== "function") throw new Error("Expected credential provider")
const credentials = yield* Effect.promise(() => credentialProvider())
expect(credentials).toMatchObject({
region: "eu-west-1",
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
})
}),
),
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
}),
)
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
+3 -2
View File
@@ -18,6 +18,7 @@ const BindingObject = Schema.StructWithRest(
Schema.Struct({
key: Schema.Union([Schema.String, KeyStroke]),
event: Schema.optional(Schema.Literals(["press", "release"])),
source: Schema.optional(Schema.Literals(["raw", "kitty"])),
preventDefault: Schema.optional(Schema.Boolean),
fallthrough: Schema.optional(Schema.Boolean),
}),
@@ -162,7 +163,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_queue: keybind({ key: "alt+return", source: "kitty" }, "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
@@ -172,7 +173,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
+26
View File
@@ -66,6 +66,32 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
}
}
const dispose = [
keymap.registerBindingFields({
source: (source, context) => {
if (source !== "raw" && source !== "kitty") throw new Error(`Invalid key source: ${String(source)}`)
context.attr("source", source)
},
}),
keymap.appendBindingTransformer((binding, context) => {
const source = binding.source
if (source !== "raw" && source !== "kitty") return
const command = binding.cmd
if (!command) return
context.add({
...binding,
cmd: (commandContext) => {
if (commandContext.event.source !== source) return false
if (typeof command === "function") return command(commandContext)
return commandContext.keymap.runCommand(command, {
event: commandContext.event,
focused: commandContext.focused,
target: commandContext.target,
payload: commandContext.payload,
})
},
})
context.skipOriginal()
}),
registerCommaBindings(keymap),
keymap.appendBindingExpander((context) => {
const key = Object.entries({ enter: "return", esc: "escape", pgdown: "pagedown", pgup: "pageup" }).reduce(
+1 -1
View File
@@ -407,7 +407,7 @@ export function RunCommandMenuBody(props: {
name: "compact",
display: "Compact session",
footer: "/compact",
keywords: "compact summarize session context",
keywords: "compact session context",
},
{
action: "slash",
+1 -1
View File
@@ -406,7 +406,7 @@ export function createPromptState(input: PromptInput): PromptState {
kind: "slash",
name: "compact",
display: "/compact",
description: "summarize the session to reduce context usage",
description: "compact older session context to free space",
} satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
+1 -2
View File
@@ -54,8 +54,7 @@ export function isNewCommand(input: string): boolean {
}
export function isCompactCommand(input: string): boolean {
const text = input.trim().toLowerCase()
return text === "/compact" || text === "/summarize"
return input.trim().toLowerCase() === "/compact"
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
@@ -635,7 +635,6 @@ export function Session() {
group: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
run: () => {
void client.api.session.compact({ sessionID: route.sessionID })
+37
View File
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { expect, test } from "bun:test"
import { ConfigProvider } from "../src/config"
import { Keymap } from "../src/context/keymap"
@@ -139,3 +140,39 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy()
}
})
test("queues explicit option enter and keeps raw option enter as newline", async () => {
async function exercise(kittyKeyboard: boolean) {
let area: TextareaRenderable | undefined
let queued = 0
function Harness() {
Keymap.createLayer(() => ({
priority: 1,
commands: [{ id: "prompt.queue", run: () => void queued++ }],
}))
return <textarea ref={(value) => (area = value)} focused />
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
),
{ kittyKeyboard },
)
try {
app.mockInput.pressEnter({ meta: true })
await app.renderOnce()
return { queued, text: area?.plainText }
} finally {
app.renderer.destroy()
}
}
expect(await exercise(true)).toEqual({ queued: 1, text: "" })
expect(await exercise(false)).toEqual({ queued: 0, text: "\n" })
})
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isCompactCommand,
isExitCommand,
isNewCommand,
movePromptHistory,
@@ -98,4 +99,10 @@ describe("run prompt shared", () => {
expect(isNewCommand(" /NEW ")).toBe(true)
expect(isNewCommand("/new now")).toBe(false)
})
test("recognizes only the compact command", () => {
expect(isCompactCommand("/compact")).toBe(true)
expect(isCompactCommand(" /COMPACT ")).toBe(true)
expect(isCompactCommand("/summarize")).toBe(false)
})
})
+2 -2
View File
@@ -82,8 +82,8 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]).toMatchObject({ key: "alt+return", source: "kitty" })
})
test("preserves disabled leader from resolved tui config", async () => {
+2 -2
View File
@@ -80,7 +80,7 @@ describe("run runtime queue", () => {
])
})
test.each(["/compact", "/summarize"])("treats %s as a local compaction command", async (command) => {
test("treats /compact as a local compaction command", async () => {
const ui = createFooterApiFixture()
const seen: string[] = []
let compacted = 0
@@ -96,7 +96,7 @@ describe("run runtime queue", () => {
},
})
ui.submit(command)
ui.submit("/compact")
ui.submit("hello")
await task