Adds TUI prompt traits, refs, and plugin slots (#20741)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer, type JSX } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
@@ -42,8 +42,9 @@ export type PromptProps = {
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
ref?: (ref: PromptRef) => void
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
normal?: string[]
|
||||
@@ -92,6 +93,7 @@ export function Prompt(props: PromptProps) {
|
||||
const kv = useKV()
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -435,11 +437,24 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.visible !== false) input?.focus()
|
||||
if (props.visible === false) input?.blur()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.traits = {
|
||||
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
|
||||
suspend: !!props.disabled || store.mode === "shell",
|
||||
status: store.mode === "shell" ? "SHELL" : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
input.extmarks.clear()
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
@@ -844,7 +859,10 @@ export function Prompt(props: PromptProps) {
|
||||
<>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => (autocomplete = r)}
|
||||
ref={(r) => {
|
||||
autocomplete = r
|
||||
setAuto(() => r)
|
||||
}}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
@@ -1060,24 +1078,27 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
{props.right}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
@@ -15,6 +15,7 @@ import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
|
||||
import { Prompt } from "../component/prompt"
|
||||
import { Slot as HostSlot } from "./slots"
|
||||
import type { useToast } from "../ui/toast"
|
||||
import { Installation } from "@/installation"
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
@@ -244,6 +245,9 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
|
||||
trigger(value) {
|
||||
input.command.trigger(value)
|
||||
},
|
||||
show() {
|
||||
input.command.show()
|
||||
},
|
||||
},
|
||||
route: {
|
||||
register(list) {
|
||||
@@ -288,14 +292,20 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
|
||||
/>
|
||||
)
|
||||
},
|
||||
Slot<Name extends string>(props: TuiSlotProps<Name>) {
|
||||
return <HostSlot {...props} />
|
||||
},
|
||||
Prompt(props) {
|
||||
return (
|
||||
<Prompt
|
||||
sessionID={props.sessionID}
|
||||
workspaceID={props.workspaceID}
|
||||
visible={props.visible}
|
||||
disabled={props.disabled}
|
||||
onSubmit={props.onSubmit}
|
||||
ref={props.ref}
|
||||
hint={props.hint}
|
||||
right={props.right}
|
||||
showPlaceholder={props.showPlaceholder}
|
||||
placeholders={props.placeholders}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type TuiPluginModule,
|
||||
type TuiPluginMeta,
|
||||
type TuiPluginStatus,
|
||||
type TuiSlotPlugin,
|
||||
type TuiTheme,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import path from "path"
|
||||
@@ -491,6 +492,9 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
trigger(value) {
|
||||
api.command.trigger(value)
|
||||
},
|
||||
show() {
|
||||
api.command.show()
|
||||
},
|
||||
}
|
||||
|
||||
const route: TuiPluginApi["route"] = {
|
||||
@@ -518,7 +522,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
let count = 0
|
||||
|
||||
const slots: TuiPluginApi["slots"] = {
|
||||
register(plugin) {
|
||||
register(plugin: TuiSlotPlugin) {
|
||||
const id = count ? `${base}:${count}` : base
|
||||
count += 1
|
||||
scope.track(host.register({ ...plugin, id }))
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||
name: K
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotMap[K]
|
||||
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
|
||||
|
||||
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||
export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
|
||||
type Slot = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
|
||||
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
|
||||
|
||||
export type HostPluginApi = TuiPluginApi
|
||||
export type HostSlots = {
|
||||
register: (plugin: HostSlotPlugin) => () => void
|
||||
register: {
|
||||
(plugin: HostSlotPlugin): () => void
|
||||
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
|
||||
}
|
||||
}
|
||||
|
||||
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||
function empty<Name extends string>(_props: TuiSlotProps<Name>) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ let view: Slot = empty
|
||||
|
||||
export const Slot: Slot = (props) => view(props)
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
if (!isRecord(value.slots)) return false
|
||||
@@ -32,7 +31,7 @@ function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
|
||||
}
|
||||
|
||||
export function setupSlots(api: HostPluginApi): HostSlots {
|
||||
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
|
||||
const reg = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
|
||||
api.renderer,
|
||||
{
|
||||
theme: api.theme,
|
||||
@@ -50,10 +49,10 @@ export function setupSlots(api: HostPluginApi): HostSlots {
|
||||
},
|
||||
)
|
||||
|
||||
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
|
||||
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(reg)
|
||||
view = (props) => slot(props)
|
||||
return {
|
||||
register(plugin) {
|
||||
register(plugin: HostSlotPlugin) {
|
||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||
return reg.register(plugin)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
@@ -20,34 +20,36 @@ export function Home() {
|
||||
const sync = useSync()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
let prompt: PromptRef | undefined
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (!prompt) return
|
||||
let sent = false
|
||||
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
setRef(r)
|
||||
promptRef.set(r)
|
||||
if (once || !r) return
|
||||
if (route.initialPrompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
once = true
|
||||
} else if (args.prompt) {
|
||||
prompt.set({ input: args.prompt, parts: [] })
|
||||
r.set(route.initialPrompt)
|
||||
once = true
|
||||
return
|
||||
}
|
||||
})
|
||||
if (!args.prompt) return
|
||||
r.set({ input: args.prompt, parts: [] })
|
||||
once = true
|
||||
}
|
||||
|
||||
// Wait for sync and model store to be ready before auto-submitting --prompt
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.ready && local.model.ready,
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
if (!prompt) return
|
||||
if (!args.prompt) return
|
||||
if (prompt.current?.input !== args.prompt) return
|
||||
prompt.submit()
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
const r = ref()
|
||||
if (sent) return
|
||||
if (!r) return
|
||||
if (!sync.ready || !local.model.ready) return
|
||||
if (!args.prompt) return
|
||||
if (r.current.input !== args.prompt) return
|
||||
sent = true
|
||||
r.submit()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -61,13 +63,11 @@ export function Home() {
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID}>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
ref={bind}
|
||||
workspaceID={route.workspaceID}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
||||
placeholders={placeholder}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
|
||||
@@ -82,6 +82,7 @@ import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -129,6 +130,8 @@ export function Session() {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
})
|
||||
const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
|
||||
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -190,12 +193,7 @@ export function Session() {
|
||||
const sdk = useSDK()
|
||||
|
||||
// Handle initial prompt from fork
|
||||
createEffect(() => {
|
||||
if (route.initialPrompt && prompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
}
|
||||
})
|
||||
|
||||
let seeded = false
|
||||
let lastSwitch: string | undefined = undefined
|
||||
sdk.event.on("message.part.updated", (evt) => {
|
||||
const part = evt.properties.part
|
||||
@@ -214,7 +212,14 @@ export function Session() {
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef
|
||||
let prompt: PromptRef | undefined
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
if (seeded || !route.initialPrompt || !r) return
|
||||
seeded = true
|
||||
r.set(route.initialPrompt)
|
||||
}
|
||||
const keybind = useKeybind()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
@@ -409,7 +414,7 @@ export function Session() {
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
setPrompt={(promptInfo) => prompt.set(promptInfo)}
|
||||
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
||||
/>
|
||||
))
|
||||
},
|
||||
@@ -510,7 +515,7 @@ export function Session() {
|
||||
toBottom()
|
||||
})
|
||||
const parts = sync.data.part[message.id]
|
||||
prompt.set(
|
||||
prompt?.set(
|
||||
parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
@@ -543,7 +548,7 @@ export function Session() {
|
||||
sdk.client.session.unrevert({
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
prompt.set({ input: "", parts: [] })
|
||||
prompt?.set({ input: "", parts: [] })
|
||||
return
|
||||
}
|
||||
sdk.client.session.revert({
|
||||
@@ -1124,7 +1129,7 @@ export function Session() {
|
||||
<DialogMessage
|
||||
messageID={message.id}
|
||||
sessionID={route.sessionID}
|
||||
setPrompt={(promptInfo) => prompt.set(promptInfo)}
|
||||
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
@@ -1154,22 +1159,28 @@ export function Session() {
|
||||
<Show when={session()?.parentID}>
|
||||
<SubagentFooter />
|
||||
</Show>
|
||||
<Prompt
|
||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
// Apply initial prompt when prompt component mounts (e.g., from fork)
|
||||
if (route.initialPrompt) {
|
||||
r.set(route.initialPrompt)
|
||||
}
|
||||
}}
|
||||
disabled={permissions().length > 0 || questions().length > 0}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
<Show when={visible()}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="session_prompt"
|
||||
mode="replace"
|
||||
session_id={route.sessionID}
|
||||
visible={visible()}
|
||||
disabled={disabled()}
|
||||
on_submit={toBottom}
|
||||
ref={bind}
|
||||
>
|
||||
<Prompt
|
||||
visible={visible()}
|
||||
ref={bind}
|
||||
disabled={disabled()}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
|
||||
@@ -520,7 +520,10 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
gap={1}
|
||||
>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (input = val)}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
input = val
|
||||
val.traits = { status: "REJECT" }
|
||||
}}
|
||||
focused
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
|
||||
@@ -380,6 +380,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
|
||||
@@ -100,7 +100,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
}}
|
||||
height={3}
|
||||
keyBindings={[{ name: "return", action: "submit" }]}
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
val.traits = { status: "FILENAME" }
|
||||
}}
|
||||
initialValue={props.defaultFilename}
|
||||
placeholder="Enter filename"
|
||||
placeholderColor={theme.textMuted}
|
||||
|
||||
@@ -45,6 +45,13 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!textarea || textarea.isDestroyed) return
|
||||
const traits = props.busy
|
||||
? {
|
||||
suspend: true,
|
||||
status: "BUSY",
|
||||
}
|
||||
: {}
|
||||
textarea.traits = traits
|
||||
if (props.busy) {
|
||||
textarea.blur()
|
||||
return
|
||||
@@ -71,7 +78,9 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
}}
|
||||
height={3}
|
||||
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
}}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={theme.textMuted}
|
||||
|
||||
@@ -258,6 +258,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.traits = { status: "FILTER" }
|
||||
setTimeout(() => {
|
||||
if (!input) return
|
||||
if (input.isDestroyed) return
|
||||
|
||||
Reference in New Issue
Block a user