This commit is contained in:
Dax Raad 2025-09-29 01:00:14 -04:00
parent 6aa56b6acb
commit 79dd081fad
15 changed files with 117 additions and 176 deletions

View file

@ -11,17 +11,22 @@ import {
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type CommandOption = DialogSelectOption & {
keybind?: keyof KeybindsConfig
}
function init() {
const [registrations, setRegistrations] = createSignal<Accessor<DialogSelectOption[]>[]>([])
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const dialog = useDialog()
const keybind = useKeybind()
const options = createMemo(() => {
return registrations().flatMap((x) => x())
})
const keybind = useKeybind()
useKeyboard((evt) => {
for (const option of options()) {
@ -42,7 +47,7 @@ function init() {
}
}
},
register(cb: () => DialogSelectOption[]) {
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
onCleanup(() => {
@ -78,6 +83,12 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: DialogSelectOption[] }) {
return <DialogSelect title="Commands" options={props.options} />
function DialogCommand(props: { options: CommandOption[] }) {
const keybind = useKeybind()
return (
<DialogSelect
title="Commands"
options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))}
/>
)
}

View file

@ -4,6 +4,7 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { Keybind } from "@/util/keybind"
export function DialogModel() {
const local = useLocal()

View file

@ -4,6 +4,7 @@ import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
export function DialogSessionList() {
const dialog = useDialog()
@ -45,6 +46,13 @@ export function DialogSessionList() {
})
dialog.clear()
}}
keybind={[
{
keybind: Keybind.parse("del")[0],
title: "delete",
onTrigger: (option) => {},
},
]}
/>
)
}

View file

@ -76,16 +76,19 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
get leader() {
return store.leader
},
match(key: keyof KeybindsConfig, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = {
parse(evt: ParsedKey) {
return {
ctrl: evt.ctrl,
name: evt.name,
shift: false,
leader: store.leader,
option: evt.option,
}
},
match(key: keyof KeybindsConfig, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
return true

View file

@ -10,7 +10,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) {
return (
<DialogSelect
title="Message"
title="Message Actions"
options={[
{
title: "Revert",

View file

@ -7,14 +7,19 @@ import { useKeyboard } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
keybind?: {
keybind: Keybind.Info
title: string
onTrigger: (option: DialogSelectOption<T>) => void
}[]
limit?: number
current?: T
}
@ -22,7 +27,6 @@ export interface DialogSelectProps<T> {
export interface DialogSelectOption<T = any> {
title: string
value: T
keybind?: keyof KeybindsConfig
description?: string
footer?: string
category?: string
@ -99,6 +103,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
}
const keybind = useKeybind()
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
@ -109,10 +114,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
for (const item of props.keybind ?? []) {
if (Keybind.match(item.keybind, keybind.parse(evt))) {
item.onTrigger(selected())
}
}
})
let scroll: ScrollBoxRenderable
const keybind = useKeybind()
return (
<box gap={1}>
@ -179,7 +189,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
>
<Option
title={option.title}
footer={option.footer ?? (option.keybind ? keybind.print(option.keybind as any) : undefined)}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={isDeepEqual(option.value, props.current)}
@ -192,17 +202,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row">
{/*
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
n
</text>
<text fg={Theme.textMuted}> new</text>
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
{" "}r
</text>
<text fg={Theme.textMuted}> rename</text>
*/}
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1}>
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: Theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: Theme.textMuted }}> {item.title}</span>
</text>
)}
</For>
</box>
</box>
)

View file

@ -7,10 +7,11 @@ import { $ } from "bun"
await $`bun tsc`
const pkg = await import("../package.json")
const pkg = await import("../package.json").then((m) => m.default)
// @ts-expect-error
delete pkg.devDependencies
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./").replace(".ts", "")
// @ts-expect-error
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",

View file

@ -7,10 +7,11 @@ import { $ } from "bun"
await import("./build")
const pkg = await import("../package.json")
const pkg = await import("../package.json").then((m) => m.default)
// @ts-expect-error
delete pkg["devDependencies"]
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./").replace(".ts", "")
// @ts-expect-error
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",

View file

@ -1,8 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from "../core/serverSentEvents.gen.js"
import type { HttpMethod } from "../core/types.gen.js"
import { getValidRequestBody } from "../core/utils.gen.js"
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js"
import {
buildUrl,
@ -51,12 +49,12 @@ export const createClient = (config: Config = {}): Client => {
await opts.requestValidator(opts)
}
if (opts.body !== undefined && opts.bodySerializer) {
if (opts.body && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body)
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === "") {
if (opts.serializedBody === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type")
}
@ -71,7 +69,7 @@ export const createClient = (config: Config = {}): Client => {
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: getValidRequestBody(opts),
body: opts.serializedBody,
}
let request = new Request(url, requestInit)
@ -99,36 +97,18 @@ export const createClient = (config: Config = {}): Client => {
}
if (response.ok) {
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
let emptyData: any
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]()
break
case "formData":
emptyData = new FormData()
break
case "stream":
emptyData = response.body
break
case "json":
default:
emptyData = {}
break
}
return opts.responseStyle === "data"
? emptyData
? {}
: {
data: emptyData,
data: {},
...result,
}
}
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
let data: any
switch (parseAs) {
case "arrayBuffer":
@ -198,53 +178,35 @@ export const createClient = (config: Config = {}): Client => {
}
}
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method })
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init)
for (const fn of interceptors.request._fns) {
if (fn) {
request = await fn(request, opts)
}
}
return request
},
url,
})
const makeMethod = (method: Required<Config>["method"]) => {
const fn = (options: RequestOptions) => request({ ...options, method })
fn.sse = async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
url,
})
}
return fn
}
return {
buildUrl,
connect: makeMethodFn("CONNECT"),
delete: makeMethodFn("DELETE"),
get: makeMethodFn("GET"),
connect: makeMethod("CONNECT"),
delete: makeMethod("DELETE"),
get: makeMethod("GET"),
getConfig,
head: makeMethodFn("HEAD"),
head: makeMethod("HEAD"),
interceptors,
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
options: makeMethod("OPTIONS"),
patch: makeMethod("PATCH"),
post: makeMethod("POST"),
put: makeMethod("PUT"),
request,
setConfig,
sse: {
connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
trace: makeMethod("TRACE"),
} as Client
}

View file

@ -20,7 +20,7 @@ export interface Config<T extends ClientOptions = ClientOptions>
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
fetch?: (request: Request) => ReturnType<typeof fetch>
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
@ -128,7 +128,7 @@ export interface ClientOptions {
throwOnError?: boolean
}
type MethodFn = <
type MethodFnBase = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
@ -137,7 +137,7 @@ type MethodFn = <
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type SseFn = <
type MethodFnServerSentEvents = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
@ -146,6 +146,10 @@ type SseFn = <
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>
type MethodFn = MethodFnBase & {
sse: MethodFnServerSentEvents
}
type RequestFn = <
TData = unknown,
TError = unknown,
@ -167,7 +171,7 @@ type BuildUrlFn = <
options: Pick<TData, "url"> & Options<TData>,
) => string
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>
}

View file

@ -162,22 +162,14 @@ export const mergeConfigs = (a: Config, b: Config): Config => {
return config
}
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = []
headers.forEach((value, key) => {
entries.push([key, value])
})
return entries
}
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers()
for (const header of headers) {
if (!header) {
if (!header || typeof header !== "object") {
continue
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header)
const iterator = header instanceof Headers ? header.entries() : Object.entries(header)
for (const [key, value] of iterator) {
if (value === null) {

View file

@ -4,17 +4,6 @@ import type { Config } from "./types.gen.js"
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
@ -32,7 +21,6 @@ export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void
serializedBody?: RequestInit["body"]
/**
* Default retry delay in milliseconds.
*
@ -76,7 +64,6 @@ export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unkn
}
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
@ -112,21 +99,7 @@ export const createSseClient = <TData = unknown>({
}
try {
const requestInit: RequestInit = {
redirect: "follow",
...options,
body: options.serializedBody,
headers,
signal,
}
let request = new Request(url, requestInit)
if (onRequest) {
request = await onRequest(url, requestInit)
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch
const response = await _fetch(request)
const response = await fetch(url, { ...options, headers, signal })
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`)

View file

@ -3,19 +3,24 @@
import type { Auth, AuthToken } from "./auth.gen.js"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js"
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
export interface Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never> {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn
connect: MethodFn
delete: MethodFn
get: MethodFn
getConfig: () => Config
head: MethodFn
options: MethodFn
patch: MethodFn
post: MethodFn
put: MethodFn
request: RequestFn
setConfig: (config: Config) => Config
} & {
[K in HttpMethod]: MethodFn
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } })
trace: MethodFn
}
export interface Config {
/**
@ -42,7 +47,7 @@ export interface Config {
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>
method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE"
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject

View file

@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js"
import type { QuerySerializer } from "./bodySerializer.gen.js"
import {
type ArraySeparatorStyle,
serializeArrayParam,
@ -107,31 +107,3 @@ export const getUrl = ({
}
return url
}
export function getValidRequestBody(options: {
body?: unknown
bodySerializer?: BodySerializer | null
serializedBody?: unknown
}) {
const hasBody = options.body !== undefined
const isSerializedBody = hasBody && options.bodySerializer
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== ""
return hasSerializedBody ? options.serializedBody : null
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== "" ? options.body : null
}
// plain/text body
if (hasBody) {
return options.body
}
// no body was provided
return undefined
}

View file

@ -676,7 +676,7 @@ class Event extends _HeyApiClient {
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
return (options?.client ?? this._client).get.sse<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
})