Skip to content

Commit 539b118

Browse files
authored
run: add shell mode to prompt (anomalyco#28315)
Press `!` on an empty prompt to enter shell mode and run a command through session.shell instead of sending a message
1 parent 11f7e5a commit 539b118

12 files changed

Lines changed: 665 additions & 47 deletions

packages/opencode/src/cli/cmd/run/footer.prompt.tsx

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type PromptInput = {
8888
export type PromptState = {
8989
placeholder: Accessor<StyledText | string>
9090
bindings: Accessor<KeyBinding[]>
91+
shell: Accessor<boolean>
9192
visible: Accessor<boolean>
9293
options: Accessor<PromptOption[]>
9394
selected: Accessor<number>
@@ -110,9 +111,14 @@ function clonePrompt(prompt: RunPrompt): RunPrompt {
110111
return {
111112
text: prompt.text,
112113
parts: structuredClone(prompt.parts),
114+
...(prompt.mode ? { mode: prompt.mode } : {}),
113115
}
114116
}
115117

118+
function emptyPrompt(shell: boolean): RunPrompt {
119+
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
120+
}
121+
116122
function removeLineRange(input: string) {
117123
const hash = input.lastIndexOf("#")
118124
return hash === -1 ? input : input.slice(0, hash)
@@ -274,7 +280,14 @@ export function RunPromptBody(props: {
274280
export function createPromptState(input: PromptInput): PromptState {
275281
const keys = createMemo(() => promptKeys(input.keybinds))
276282
const bindings = createMemo(() => keys().bindings)
283+
const [shell, setShell] = createSignal(false)
277284
const placeholder = createMemo(() => {
285+
if (shell()) {
286+
return new StyledText([
287+
bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"')),
288+
])
289+
}
290+
278291
if (!input.state().first) {
279292
return ""
280293
}
@@ -301,6 +314,11 @@ export function createPromptState(input: PromptInput): PromptState {
301314
const [query, setQuery] = createSignal("")
302315
const visible = createMemo(() => mode() !== false)
303316

317+
const setShellMode = (value: boolean) => {
318+
setShell(value)
319+
draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) }
320+
}
321+
304322
const width = createMemo(() => Math.max(20, input.width() - 8))
305323
const agents = createMemo<Auto[]>(() => {
306324
return input
@@ -577,6 +595,7 @@ export function createPromptState(input: PromptInput): PromptState {
577595

578596
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
579597
draft = clonePrompt(value)
598+
setShell(value.mode === "shell")
580599
if (!area || area.isDestroyed) {
581600
return
582601
}
@@ -596,7 +615,7 @@ export function createPromptState(input: PromptInput): PromptState {
596615

597616
clearParts()
598617
hide()
599-
draft = { text: "", parts: [] }
618+
draft = emptyPrompt(shell())
600619
if (!area || area.isDestroyed) {
601620
return
602621
}
@@ -606,15 +625,15 @@ export function createPromptState(input: PromptInput): PromptState {
606625
}
607626

608627
const replaceDraft = (text: string) => {
609-
draft = { text, parts: [] }
628+
draft = shell() ? { text, parts: [], mode: "shell" } : { text, parts: [] }
610629
if (!area || area.isDestroyed) {
611630
return
612631
}
613632

614633
hide()
615634
area.setText(text)
616635
clearParts()
617-
draft = { text: area.plainText, parts: [] }
636+
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
618637
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
619638
scheduleRows()
620639
area.focus()
@@ -705,10 +724,16 @@ export function createPromptState(input: PromptInput): PromptState {
705724
}
706725

707726
syncParts()
708-
draft = {
709-
text: area.plainText,
710-
parts: structuredClone(parts),
711-
}
727+
draft = shell()
728+
? {
729+
text: area.plainText,
730+
parts: structuredClone(parts),
731+
mode: "shell",
732+
}
733+
: {
734+
text: area.plainText,
735+
parts: structuredClone(parts),
736+
}
712737
}
713738

714739
const push = (value: RunPrompt) => {
@@ -943,6 +968,35 @@ export function createPromptState(input: PromptInput): PromptState {
943968
}
944969
}
945970

971+
if (
972+
key.name === "!" &&
973+
!shell() &&
974+
!event.ctrl &&
975+
!event.meta &&
976+
!event.super &&
977+
area &&
978+
!area.isDestroyed &&
979+
area.cursorOffset === 0
980+
) {
981+
event.preventDefault()
982+
setShellMode(true)
983+
return
984+
}
985+
986+
if (shell() && !visible()) {
987+
if (key.name === "escape") {
988+
event.preventDefault()
989+
setShellMode(false)
990+
return
991+
}
992+
993+
if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) {
994+
event.preventDefault()
995+
setShellMode(false)
996+
return
997+
}
998+
}
999+
9461000
if (promptHit(keys().clear, key)) {
9471001
const handled = requestExit()
9481002
if (handled) {
@@ -1028,23 +1082,28 @@ export function createPromptState(input: PromptInput): PromptState {
10281082
return
10291083
}
10301084

1031-
if (isExitCommand(next.text)) {
1085+
if (next.mode !== "shell" && isExitCommand(next.text)) {
10321086
input.onExit()
10331087
return
10341088
}
10351089

1036-
const parsed = isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
1090+
const parsed = next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
10371091
if (parsed?.type === "pending") {
10381092
input.onStatus("loading commands")
10391093
return
10401094
}
10411095

10421096
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
1097+
const shellMode = next.mode === "shell"
10431098

10441099
resetDraft()
10451100
queueMicrotask(async () => {
10461101
if (await input.onSubmit(submit)) {
10471102
push(next)
1103+
if (shellMode) {
1104+
setShellMode(false)
1105+
draft = emptyPrompt(false)
1106+
}
10481107
return
10491108
}
10501109

@@ -1121,6 +1180,7 @@ export function createPromptState(input: PromptInput): PromptState {
11211180
return {
11221181
placeholder,
11231182
bindings,
1183+
shell,
11241184
visible,
11251185
options,
11261186
selected: menu.selected,

packages/opencode/src/cli/cmd/run/footer.view.tsx

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export function RunFooterView(props: RunFooterViewProps) {
265265
onRows: props.onRows,
266266
onStatus: props.onStatus,
267267
})
268+
const shell = createMemo(() => prompt() && composer.shell())
268269
const menu = createMemo(() => prompt() && composer.visible())
269270

270271
createEffect(() => {
@@ -487,18 +488,20 @@ export function RunFooterView(props: RunFooterViewProps) {
487488
paddingTop={1}
488489
>
489490
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
490-
{props.agent}
491-
</text>
492-
<text
493-
id="run-direct-footer-model"
494-
fg={theme().text}
495-
wrapMode="none"
496-
truncate
497-
flexGrow={1}
498-
flexShrink={1}
499-
>
500-
{props.state().model}
491+
{shell() ? "Shell" : props.agent}
501492
</text>
493+
<Show when={!shell()}>
494+
<text
495+
id="run-direct-footer-model"
496+
fg={theme().text}
497+
wrapMode="none"
498+
truncate
499+
flexGrow={1}
500+
flexShrink={1}
501+
>
502+
{props.state().model}
503+
</text>
504+
</Show>
502505
</box>
503506
</Show>
504507
</box>
@@ -629,19 +632,30 @@ export function RunFooterView(props: RunFooterViewProps) {
629632
flexShrink={0}
630633
justifyContent="flex-end"
631634
>
632-
<Show when={queue() > 0}>
633-
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
634-
{queue()} queued
635-
</text>
636-
</Show>
637-
<Show when={usage().length > 0}>
638-
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
639-
{usage()}
640-
</text>
641-
</Show>
642-
<Show when={command().length > 0 && hints().command}>
643-
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
644-
{command()} <span style={{ fg: theme().muted }}>commands</span>
635+
<Show
636+
when={shell()}
637+
fallback={
638+
<>
639+
<Show when={queue() > 0}>
640+
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
641+
{queue()} queued
642+
</text>
643+
</Show>
644+
<Show when={usage().length > 0}>
645+
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
646+
{usage()}
647+
</text>
648+
</Show>
649+
<Show when={command().length > 0 && hints().command}>
650+
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
651+
{command()} <span style={{ fg: theme().muted }}>commands</span>
652+
</text>
653+
</Show>
654+
</>
655+
}
656+
>
657+
<text id="run-direct-footer-hint-shell" fg={theme().text} wrapMode="none" truncate>
658+
esc <span style={{ fg: theme().muted }}>exit shell mode</span>
645659
</text>
646660
</Show>
647661
</box>

packages/opencode/src/cli/cmd/run/prompt.shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
6565
return {
6666
text: prompt.text,
6767
parts: structuredClone(prompt.parts),
68+
...(prompt.mode ? { mode: prompt.mode } : {}),
6869
}
6970
}
7071

7172
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
72-
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
73+
return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
7374
}
7475

7576
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {

packages/opencode/src/cli/cmd/run/runtime.queue.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
102102
continue
103103
}
104104

105-
if (isNewCommand(prompt.text)) {
105+
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
106106
emit(
107107
{
108108
type: "queue",
@@ -167,9 +167,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
167167
break
168168
}
169169

170-
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
171-
input.trace?.write("ui.commit", commit)
172-
input.footer.append(commit)
170+
if (prompt.mode !== "shell") {
171+
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
172+
input.trace?.write("ui.commit", commit)
173+
input.footer.append(commit)
174+
}
173175
input.onSend?.(prompt)
174176

175177
if (state.closed) {
@@ -234,7 +236,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
234236
return
235237
}
236238

237-
if (isExitCommand(prompt.text)) {
239+
if (prompt.mode !== "shell" && isExitCommand(prompt.text)) {
238240
input.footer.close()
239241
return
240242
}
@@ -249,7 +251,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
249251
queue: state.queue.length,
250252
},
251253
)
252-
if (isNewCommand(prompt.text)) {
254+
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
253255
drain()
254256
return
255257
}

0 commit comments

Comments
 (0)