enable prompt shell mode

This commit is contained in:
Shantur Rathore
2025-11-16 20:50:20 +00:00
parent 6658c0b15a
commit eb279cf251
5 changed files with 148 additions and 17 deletions

View File

@@ -16,6 +16,7 @@ interface PromptInputProps {
instanceFolder: string
sessionId: string
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean
escapeInDebounce?: boolean
}
@@ -33,6 +34,7 @@ export default function PromptInput(props: PromptInputProps) {
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
@@ -51,6 +53,7 @@ export default function PromptInput(props: PromptInputProps) {
clearSessionDraftPrompt(props.instanceId, props.sessionId)
setPromptInternal("")
setHistoryDraft(null)
setMode("normal")
}
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
@@ -295,9 +298,40 @@ export default function PromptInput(props: PromptInputProps) {
return
}
const currentText = prompt()
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
const isShellMode = mode() === "shell"
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !props.disabled) {
e.preventDefault()
setMode("shell")
return
}
if (showPicker() && e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
handlePickerClose()
return
}
if (isShellMode) {
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setMode("normal")
return
}
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
e.preventDefault()
setMode("normal")
return
}
}
if (e.key === "Backspace" || e.key === "Delete") {
const cursorPos = textarea.selectionStart
const text = prompt()
const text = currentText
const pastePlaceholderRegex = /\[pasted #(\d+)\]/g
let pasteMatch
@@ -464,9 +498,10 @@ export default function PromptInput(props: PromptInputProps) {
async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
if (!text || props.disabled) return
if (props.disabled || !text) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell"
clearPrompt()
clearAttachments(props.instanceId, props.sessionId)
@@ -480,7 +515,15 @@ export default function PromptInput(props: PromptInputProps) {
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
await props.onSend(text, currentAttachments)
if (isShellMode) {
if (props.onRunShell) {
await props.onRunShell(resolvedPrompt)
} else {
await props.onSend(resolvedPrompt, [])
}
} else {
await props.onSend(text, currentAttachments)
}
} catch (error) {
console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
@@ -667,7 +710,14 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus()
}
const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !props.disabled
const canSend = () => {
if (props.disabled) return false
const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText
return hasText || attachments().length > 0
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const instance = () => getActiveInstance()
@@ -676,7 +726,11 @@ export default function PromptInput(props: PromptInputProps) {
<div
ref={containerRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
style={isDragging() ? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);" : ""}
style={
isDragging()
? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);"
: ""
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -768,8 +822,12 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message, @file, @agent, or paste images and text..."
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
@@ -786,22 +844,37 @@ export default function PromptInput(props: PromptInputProps) {
/>
</div>
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
<span class="send-icon"></span>
<button
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
onClick={handleSend}
disabled={!canSend()}
aria-label="Send message"
>
<Show
when={mode() === "shell"}
fallback={<span class="send-icon"></span>}
>
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
</svg>
</Show>
</button>
</div>
<div class="prompt-input-hints">
<div class="flex justify-end">
<div class="flex justify-between w-full gap-4">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents {" "}
<Kbd></Kbd> for history
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
<span class="ml-2">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
</>
}
>
@@ -810,6 +883,9 @@ export default function PromptInput(props: PromptInputProps) {
</span>
</Show>
</HintRow>
<Show when={mode() === "shell"}>
<HintRow>Shell mode active</HintRow>
</Show>
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import type { ClientPart } from "../../types/message"
import MessageStream from "../message-stream"
import PromptInput from "../prompt-input"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession } from "../../stores/sessions"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
interface SessionViewProps {
sessionId: string
@@ -30,6 +30,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
}
async function handleRunShell(command: string) {
await runShellCommand(props.instanceId, props.sessionId, command)
}
function getUserMessageText(messageId: string): string | null {
const currentSession = session()
if (!currentSession) return null
@@ -134,6 +138,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
instanceFolder={props.instanceFolder}
sessionId={s().id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
/>
</div>

View File

@@ -8,10 +8,7 @@ import {
preferences,
setAgentModelPreference,
} from "./preferences"
import {
sessions,
withSession,
} from "./session-state"
import { sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
@@ -249,6 +246,28 @@ async function executeCustomCommand(
})
}
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const agent = session.agent || "build"
await instance.client.session.shell({
path: { id: sessionId },
body: {
agent,
command,
},
})
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -326,6 +345,7 @@ async function updateSessionModel(
export {
abortSession,
executeCustomCommand,
runShellCommand,
sendMessage,
updateSessionAgent,
updateSessionModel,

View File

@@ -41,6 +41,7 @@ import {
import {
abortSession,
executeCustomCommand,
runShellCommand,
sendMessage,
updateSessionAgent,
updateSessionModel,
@@ -82,6 +83,7 @@ export {
createSession,
deleteSession,
executeCustomCommand,
runShellCommand,
fetchAgents,
fetchProviders,
fetchSessions,

View File

@@ -9,6 +9,7 @@
@apply flex items-end gap-2 p-3;
}
.prompt-input {
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
font-family: inherit;
@@ -18,10 +19,20 @@
line-height: var(--line-height-normal);
}
.prompt-input.shell-mode {
border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
}
.prompt-input:focus {
border-color: var(--accent-primary);
}
.prompt-input.shell-mode:focus {
border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
}
.prompt-input:disabled {
@apply opacity-60 cursor-not-allowed;
}
@@ -36,6 +47,18 @@
color: var(--text-inverted);
}
.send-button.shell-mode {
background-color: var(--status-success);
}
.send-button.shell-mode:hover:not(:disabled) {
filter: brightness(1.05);
}
.send-button.shell-mode:active:not(:disabled) {
filter: brightness(0.95);
}
.send-button:hover:not(:disabled) {
@apply opacity-90 scale-105;
}
@@ -52,6 +75,11 @@
@apply text-base;
}
.shell-icon {
width: 1rem;
height: 1rem;
}
.prompt-input-hints {
@apply px-4 pb-2 flex justify-between items-center;
}