Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton
7bdb4e6280 test(webfetch): keep tool execution in scope
Execute the webfetch tool inside the same provided Effect scope used for initialization so the HTTP client layer stays alive for the assertions instead of interrupting the test fibers.
2026-04-12 08:17:46 -04:00
Kit Langton
a550ebf682 refactor(tool): yield Truncate and Agent in Tool.define, close over in wrap
Services are resolved at define-time (in registry context), passed into
wrap as closed-over values. Info.init() and Tool.init() stay R=never.
Tests updated to provide Truncate.defaultLayer and Agent.defaultLayer.
2026-04-12 08:17:46 -04:00
15 changed files with 20 additions and 214 deletions

View File

@@ -59,7 +59,6 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { Keybind } from "@/util/keybind"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -311,7 +310,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
// - Most other key input dismisses selection and is passed through
if (Keybind.matchParsedKey("ctrl+c", evt)) {
if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) {
renderer.clearSelection()
return

View File

@@ -47,7 +47,7 @@ export function DialogMcp() {
const keybinds = createMemo(() => [
{
keybind: Keybind.parseOne("space"),
keybind: Keybind.parse("space")[0],
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress

View File

@@ -162,7 +162,7 @@ export function DialogSessionList() {
},
},
{
keybind: Keybind.parseOne("ctrl+w"),
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,

View File

@@ -5,7 +5,6 @@ import { createSignal } from "solid-js"
import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
import { Keybind } from "@/util/keybind"
export function ErrorComponent(props: {
error: Error
@@ -26,7 +25,7 @@ export function ErrorComponent(props: {
}
useKeyboard((evt) => {
if (Keybind.matchParsedKey("ctrl+c", evt)) {
if (evt.ctrl && evt.name === "c") {
handleExit()
}
})

View File

@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
useKeyboard((evt) => {
setStore("input", "keyboard")
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1)
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1)
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)

View File

@@ -6,7 +6,6 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection"
import { Keybind } from "@/util/keybind"
export function Dialog(
props: ParentProps<{
@@ -73,13 +72,12 @@ function init() {
})
const renderer = useRenderer()
useKeyboard((evt) => {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt)
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || isCtrlC) {
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}

View File

@@ -5,7 +5,6 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" })
@@ -28,21 +27,6 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
}
// Check if a message is a synthetic user msg used to attach an image from a tool call
function imgMsg(msg: any): boolean {
if (msg?.role !== "user") return false
// Handle the 3 api formats
const content = msg.content
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
if (!Array.isArray(content)) return false
return content.some(
(part: any) =>
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)
}
function fix(model: Model, url: string): Model {
return {
...model,
@@ -106,7 +90,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
),
isAgent: last?.role !== "user" || imgMsg(last),
isAgent: last?.role !== "user",
}
}
@@ -118,7 +102,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
),
isAgent: last?.role !== "user" || imgMsg(last),
isAgent: last?.role !== "user",
}
}
@@ -140,7 +124,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")),
),
),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
isAgent: !(last?.role === "user" && hasNonToolCalls),
}
}
} catch {}

View File

@@ -25,8 +25,6 @@ interface FetchDecompressionError extends Error {
}
export namespace MessageV2 {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf"
}
@@ -810,7 +808,7 @@ export namespace MessageV2 {
parts: [
{
type: "text" as const,
text: SYNTHETIC_ATTACHMENT_PROMPT,
text: "Attached image(s) from tool result:",
},
...media.map((attachment) => ({
type: "file" as const,

View File

@@ -6,70 +6,15 @@ export namespace Keybind {
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
* This ensures type compatibility and catches missing fields at compile time.
*/
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
leader: boolean // our custom field
}
function getBaseCodeName(baseCode: number | undefined): string | undefined {
if (baseCode === undefined || baseCode < 32 || baseCode === 127) {
return undefined
}
try {
const name = String.fromCodePoint(baseCode)
if (name.length === 1 && name >= "A" && name <= "Z") {
return name.toLowerCase()
}
return name
} catch {
return undefined
}
}
export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false
const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false }
if (isDeepEqual(normalizedA, normalizedB)) {
return true
}
const modifiersA = {
ctrl: normalizedA.ctrl,
meta: normalizedA.meta,
shift: normalizedA.shift,
super: normalizedA.super,
leader: normalizedA.leader,
}
const modifiersB = {
ctrl: normalizedB.ctrl,
meta: normalizedB.meta,
shift: normalizedB.shift,
super: normalizedB.super,
leader: normalizedB.leader,
}
if (!isDeepEqual(modifiersA, modifiersB)) {
return false
}
return (
normalizedA.name === normalizedB.name ||
getBaseCodeName(normalizedA.baseCode) === normalizedB.name ||
getBaseCodeName(normalizedB.baseCode) === normalizedA.name
)
}
export function parseOne(key: string): Info {
const parsed = parse(key)
if (parsed.length !== 1) {
throw new Error(`Expected exactly one keybind, got ${parsed.length}: ${key}`)
}
return parsed[0]!
return isDeepEqual(normalizedA, normalizedB)
}
/**
@@ -83,23 +28,10 @@ export namespace Keybind {
meta: key.meta,
shift: key.shift,
super: key.super ?? false,
baseCode: key.baseCode,
leader,
}
}
export function matchParsedKey(binding: Info | string | undefined, key: ParsedKey, leader = false): boolean {
const bindings = typeof binding === "string" ? parse(binding) : binding ? [binding] : []
if (!bindings.length) {
return false
}
const parsed = fromParsedKey(key, leader)
return bindings.some((item) => match(item, parsed))
}
export function toString(info: Info | undefined): string {
if (!info) return ""
const parts: string[] = []

View File

@@ -162,24 +162,6 @@ describe("Keybind.match", () => {
expect(Keybind.match(a, b)).toBe(true)
})
test("should match ctrl shortcuts by baseCode from alternate layouts", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
expect(Keybind.match(a, b)).toBe(true)
})
test("should still match the reported character when baseCode is also present", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match a different shortcut just because baseCode exists", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match super+shift combination", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
@@ -437,68 +419,3 @@ describe("Keybind.parse", () => {
])
})
})
describe("Keybind.parseOne", () => {
test("should parse a single keybind", () => {
expect(Keybind.parseOne("ctrl+x")).toEqual({
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
})
})
test("should reject multiple keybinds", () => {
expect(() => Keybind.parseOne("ctrl+x,ctrl+y")).toThrow("Expected exactly one keybind")
})
})
describe("Keybind.fromParsedKey", () => {
test("should preserve baseCode from ParsedKey", () => {
const result = Keybind.fromParsedKey({
name: "ㅊ",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "ㅊ",
raw: "\x1b[12618::99;5u",
eventType: "press",
source: "kitty",
baseCode: 99,
})
expect(result).toEqual({
name: "ㅊ",
ctrl: true,
meta: false,
shift: false,
super: false,
leader: false,
baseCode: 99,
})
})
test("should ignore leader unless explicitly requested", () => {
const key = {
name: "ㅊ",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "ㅊ",
raw: "\x1b[12618::99;5u",
eventType: "press" as const,
source: "kitty" as const,
baseCode: 99,
}
expect(Keybind.matchParsedKey("ctrl+c", key)).toBe(true)
expect(Keybind.matchParsedKey("ctrl+x,ctrl+c", key)).toBe(true)
expect(Keybind.matchParsedKey("ctrl+x,ctrl+y", key)).toBe(false)
expect(Keybind.matchParsedKey("ctrl+c", key, true)).toBe(false)
})
})

View File

@@ -14,14 +14,7 @@ import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
)
const baseCtx = {

View File

@@ -36,15 +36,7 @@ async function touch(file: string, time: number) {
}
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
)
afterAll(async () => {

View File

@@ -9,9 +9,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))

View File

@@ -21,9 +21,7 @@ const ctx = {
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) {

View File

@@ -152,9 +152,7 @@ Use this skill.
await Instance.provide({
directory: tmp.path,
fn: async () => {
const runtime = ManagedRuntime.make(
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
const info = await runtime.runPromise(SkillTool)
const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []