mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
tui: add shell command mode that lets you run system commands directly from the prompt by typing '!' at the beginning
This commit is contained in:
parent
76b303c43f
commit
2c16a77862
3 changed files with 169 additions and 135 deletions
|
|
@ -47,9 +47,15 @@ export function Prompt(props: PromptProps) {
|
|||
if (!props.disabled) input.cursorColor = Theme.primary
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<PromptInfo>({
|
||||
input: "",
|
||||
parts: [],
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
}>({
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
},
|
||||
mode: "normal",
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -67,17 +73,93 @@ export function Prompt(props: PromptProps) {
|
|||
input.blur()
|
||||
},
|
||||
set(prompt) {
|
||||
setStore(prompt)
|
||||
setStore("prompt", prompt)
|
||||
input.cursorPosition = prompt.input.length
|
||||
},
|
||||
reset() {
|
||||
setStore({
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (props.disabled) return
|
||||
if (autocomplete.visible) return
|
||||
if (!store.prompt.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
const input = store.prompt.input
|
||||
if (store.mode === "shell") {
|
||||
sdk.session.shell({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
agent: local.agent.current().name,
|
||||
command: input,
|
||||
},
|
||||
})
|
||||
} else if (input.startsWith("/")) {
|
||||
const [command, ...args] = input.split(" ")
|
||||
sdk.session.command({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
command: command.slice(1),
|
||||
arguments: args.join(" "),
|
||||
agent: local.agent.current().name,
|
||||
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
|
||||
messageID,
|
||||
},
|
||||
})
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
return
|
||||
} else {
|
||||
sdk.session.prompt({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
...local.model.current(),
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: local.model.current(),
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input,
|
||||
},
|
||||
...store.prompt.parts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
props.onSubmit?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
|
|
@ -86,16 +168,20 @@ export function Prompt(props: PromptProps) {
|
|||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore(produce(cb))
|
||||
input.cursorPosition = store.input.length
|
||||
setStore("prompt", produce(cb))
|
||||
input.cursorPosition = store.prompt.input.length
|
||||
}}
|
||||
value={store.input}
|
||||
value={store.prompt.input}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box flexDirection="row" {...SplitBorder} borderColor={keybind.leader ? Theme.accent : undefined}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
{...SplitBorder}
|
||||
borderColor={keybind.leader ? Theme.accent : store.mode === "shell" ? Theme.secondary : undefined}
|
||||
>
|
||||
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
|
||||
{">"}
|
||||
{store.mode === "normal" ? ">" : "!"}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
|
||||
|
|
@ -107,23 +193,24 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
}}
|
||||
onInput={(value) => {
|
||||
let diff = value.length - store.input.length
|
||||
let diff = value.length - store.prompt.input.length
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = value
|
||||
for (let i = 0; i < draft.parts.length; i++) {
|
||||
const part = draft.parts[i]
|
||||
draft.prompt.input = value
|
||||
for (let i = 0; i < draft.prompt.parts.length; i++) {
|
||||
const part = draft.prompt.parts[i]
|
||||
if (!part.source) continue
|
||||
const source = part.type === "agent" ? part.source : part.source.text
|
||||
if (source.start >= input.cursorPosition) {
|
||||
source.start += diff
|
||||
source.end += diff
|
||||
}
|
||||
const sliced = draft.input.slice(source.start, source.end)
|
||||
const sliced = draft.prompt.input.slice(source.start, source.end)
|
||||
if (sliced != source.value && diff < 0) {
|
||||
diff -= source.value.length
|
||||
draft.input = draft.input.slice(0, source.start) + draft.input.slice(source.end)
|
||||
draft.parts.splice(i, 1)
|
||||
draft.prompt.input =
|
||||
draft.prompt.input.slice(0, source.start) + draft.prompt.input.slice(source.end)
|
||||
draft.prompt.parts.splice(i, 1)
|
||||
input.cursorPosition = Math.max(0, source.start - 1)
|
||||
i--
|
||||
}
|
||||
|
|
@ -132,18 +219,28 @@ export function Prompt(props: PromptProps) {
|
|||
)
|
||||
autocomplete.onInput(value)
|
||||
}}
|
||||
value={store.input}
|
||||
value={store.prompt.input}
|
||||
onKeyDown={async (e) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
autocomplete.onKeyDown(e)
|
||||
if (e.name === "!" && input.cursorPosition === 0) {
|
||||
setStore("mode", "shell")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.name === "backspace" && input.cursorPosition === 0 && store.mode === "shell") {
|
||||
setStore("mode", "normal")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (e.name === "up" || e.name === "down") {
|
||||
const direction = e.name === "up" ? -1 : 1
|
||||
const item = history.move(direction)
|
||||
setStore(item)
|
||||
setStore("prompt", item)
|
||||
input.cursorPosition = item.input.length
|
||||
return
|
||||
}
|
||||
|
|
@ -160,7 +257,7 @@ export function Prompt(props: PromptProps) {
|
|||
setTimeout(() => {
|
||||
const position = input.cursorPosition
|
||||
const direction = Math.sign(old - position)
|
||||
for (const part of store.parts) {
|
||||
for (const part of store.prompt.parts) {
|
||||
const source = iife(() => {
|
||||
if (part.type === "agent") return part.source
|
||||
if (part.type === "file") return part.source?.text
|
||||
|
|
@ -179,75 +276,7 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
}, 0)
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
if (props.disabled) return
|
||||
if (autocomplete.visible) return
|
||||
if (!store.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
const input = store.input
|
||||
if (input.startsWith("/")) {
|
||||
const [command, ...args] = input.split(" ")
|
||||
sdk.session.command({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
command: command.slice(1),
|
||||
arguments: args.join(" "),
|
||||
agent: local.agent.current().name,
|
||||
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
|
||||
messageID,
|
||||
},
|
||||
})
|
||||
setStore({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
props.onSubmit?.()
|
||||
return
|
||||
}
|
||||
const parts = store.parts
|
||||
history.append(store)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = ""
|
||||
draft.parts = []
|
||||
}),
|
||||
)
|
||||
sdk.session.prompt({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
...local.model.current(),
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: local.model.current(),
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input,
|
||||
},
|
||||
...parts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
props.onSubmit?.()
|
||||
}}
|
||||
onSubmit={submit}
|
||||
ref={(r) => (input = r)}
|
||||
onMouseDown={(r) => r.target?.focus()}
|
||||
focusedBackgroundColor={Theme.backgroundElement}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
|||
timeout = setTimeout(() => {
|
||||
if (!store.leader) return
|
||||
leader(false)
|
||||
if (focus) {
|
||||
focus.focus()
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,49 +357,51 @@ function UserMessage(props: { message: UserMessage; parts: Part[]; onMouseUp: ()
|
|||
const [hover, setHover] = createSignal(false)
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={props.onMouseUp}
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
backgroundColor={hover() ? Theme.backgroundElement : Theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return Theme.accent
|
||||
if (file.mime === "application/pdf") return Theme.primary
|
||||
return Theme.secondary
|
||||
})
|
||||
return (
|
||||
<text>
|
||||
<span style={{ bg: bg(), fg: Theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||
<span style={{ bg: Theme.backgroundElement, fg: Theme.textMuted }}> {file.filename} </span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text>
|
||||
{sync.data.config.username ?? "You"}{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>({Locale.time(props.message.time.created)})</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={text()}>
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={props.onMouseUp}
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
backgroundColor={hover() ? Theme.backgroundElement : Theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return Theme.accent
|
||||
if (file.mime === "application/pdf") return Theme.primary
|
||||
return Theme.secondary
|
||||
})
|
||||
return (
|
||||
<text>
|
||||
<span style={{ bg: bg(), fg: Theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||
<span style={{ bg: Theme.backgroundElement, fg: Theme.textMuted }}> {file.filename} </span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text>
|
||||
{sync.data.config.username ?? "You"}{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>({Locale.time(props.message.time.created)})</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -633,14 +635,14 @@ ToolRegistry.register<typeof BashTool>({
|
|||
return (
|
||||
<>
|
||||
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
|
||||
{props.input.description}
|
||||
{props.input.description || "Shell"}
|
||||
</ToolTitle>
|
||||
<Show when={props.input.command}>
|
||||
<text fg={Theme.text}>$ {props.input.command}</text>
|
||||
</Show>
|
||||
<Show when={props.output?.trim()}>
|
||||
<box>
|
||||
<text fg={Theme.text}>{props.output?.trim()}</text>
|
||||
<text fg={Theme.text}>{Bun.stripANSI(props.output!.trim())}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue