mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 19:05:38 +00:00
run: add shell mode to prompt (#28315)
Press `!` on an empty prompt to enter shell mode and run a command through session.shell instead of sending a message
This commit is contained in:
@@ -88,6 +88,7 @@ type PromptInput = {
|
|||||||
export type PromptState = {
|
export type PromptState = {
|
||||||
placeholder: Accessor<StyledText | string>
|
placeholder: Accessor<StyledText | string>
|
||||||
bindings: Accessor<KeyBinding[]>
|
bindings: Accessor<KeyBinding[]>
|
||||||
|
shell: Accessor<boolean>
|
||||||
visible: Accessor<boolean>
|
visible: Accessor<boolean>
|
||||||
options: Accessor<PromptOption[]>
|
options: Accessor<PromptOption[]>
|
||||||
selected: Accessor<number>
|
selected: Accessor<number>
|
||||||
@@ -110,9 +111,14 @@ function clonePrompt(prompt: RunPrompt): RunPrompt {
|
|||||||
return {
|
return {
|
||||||
text: prompt.text,
|
text: prompt.text,
|
||||||
parts: structuredClone(prompt.parts),
|
parts: structuredClone(prompt.parts),
|
||||||
|
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyPrompt(shell: boolean): RunPrompt {
|
||||||
|
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
|
||||||
|
}
|
||||||
|
|
||||||
function removeLineRange(input: string) {
|
function removeLineRange(input: string) {
|
||||||
const hash = input.lastIndexOf("#")
|
const hash = input.lastIndexOf("#")
|
||||||
return hash === -1 ? input : input.slice(0, hash)
|
return hash === -1 ? input : input.slice(0, hash)
|
||||||
@@ -274,7 +280,14 @@ export function RunPromptBody(props: {
|
|||||||
export function createPromptState(input: PromptInput): PromptState {
|
export function createPromptState(input: PromptInput): PromptState {
|
||||||
const keys = createMemo(() => promptKeys(input.keybinds))
|
const keys = createMemo(() => promptKeys(input.keybinds))
|
||||||
const bindings = createMemo(() => keys().bindings)
|
const bindings = createMemo(() => keys().bindings)
|
||||||
|
const [shell, setShell] = createSignal(false)
|
||||||
const placeholder = createMemo(() => {
|
const placeholder = createMemo(() => {
|
||||||
|
if (shell()) {
|
||||||
|
return new StyledText([
|
||||||
|
bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"')),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
if (!input.state().first) {
|
if (!input.state().first) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -301,6 +314,11 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
const visible = createMemo(() => mode() !== false)
|
const visible = createMemo(() => mode() !== false)
|
||||||
|
|
||||||
|
const setShellMode = (value: boolean) => {
|
||||||
|
setShell(value)
|
||||||
|
draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) }
|
||||||
|
}
|
||||||
|
|
||||||
const width = createMemo(() => Math.max(20, input.width() - 8))
|
const width = createMemo(() => Math.max(20, input.width() - 8))
|
||||||
const agents = createMemo<Auto[]>(() => {
|
const agents = createMemo<Auto[]>(() => {
|
||||||
return input
|
return input
|
||||||
@@ -577,6 +595,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
|
|
||||||
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
|
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
|
||||||
draft = clonePrompt(value)
|
draft = clonePrompt(value)
|
||||||
|
setShell(value.mode === "shell")
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -596,7 +615,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
|
|
||||||
clearParts()
|
clearParts()
|
||||||
hide()
|
hide()
|
||||||
draft = { text: "", parts: [] }
|
draft = emptyPrompt(shell())
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -606,7 +625,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const replaceDraft = (text: string) => {
|
const replaceDraft = (text: string) => {
|
||||||
draft = { text, parts: [] }
|
draft = shell() ? { text, parts: [], mode: "shell" } : { text, parts: [] }
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -614,7 +633,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
hide()
|
hide()
|
||||||
area.setText(text)
|
area.setText(text)
|
||||||
clearParts()
|
clearParts()
|
||||||
draft = { text: area.plainText, parts: [] }
|
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
|
||||||
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
|
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
|
||||||
scheduleRows()
|
scheduleRows()
|
||||||
area.focus()
|
area.focus()
|
||||||
@@ -705,10 +724,16 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncParts()
|
syncParts()
|
||||||
draft = {
|
draft = shell()
|
||||||
text: area.plainText,
|
? {
|
||||||
parts: structuredClone(parts),
|
text: area.plainText,
|
||||||
}
|
parts: structuredClone(parts),
|
||||||
|
mode: "shell",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: area.plainText,
|
||||||
|
parts: structuredClone(parts),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const push = (value: RunPrompt) => {
|
const push = (value: RunPrompt) => {
|
||||||
@@ -943,6 +968,35 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
key.name === "!" &&
|
||||||
|
!shell() &&
|
||||||
|
!event.ctrl &&
|
||||||
|
!event.meta &&
|
||||||
|
!event.super &&
|
||||||
|
area &&
|
||||||
|
!area.isDestroyed &&
|
||||||
|
area.cursorOffset === 0
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
setShellMode(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shell() && !visible()) {
|
||||||
|
if (key.name === "escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
setShellMode(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
setShellMode(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (promptHit(keys().clear, key)) {
|
if (promptHit(keys().clear, key)) {
|
||||||
const handled = requestExit()
|
const handled = requestExit()
|
||||||
if (handled) {
|
if (handled) {
|
||||||
@@ -1028,23 +1082,28 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExitCommand(next.text)) {
|
if (next.mode !== "shell" && isExitCommand(next.text)) {
|
||||||
input.onExit()
|
input.onExit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
|
const parsed = next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
|
||||||
if (parsed?.type === "pending") {
|
if (parsed?.type === "pending") {
|
||||||
input.onStatus("loading commands")
|
input.onStatus("loading commands")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
|
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
|
||||||
|
const shellMode = next.mode === "shell"
|
||||||
|
|
||||||
resetDraft()
|
resetDraft()
|
||||||
queueMicrotask(async () => {
|
queueMicrotask(async () => {
|
||||||
if (await input.onSubmit(submit)) {
|
if (await input.onSubmit(submit)) {
|
||||||
push(next)
|
push(next)
|
||||||
|
if (shellMode) {
|
||||||
|
setShellMode(false)
|
||||||
|
draft = emptyPrompt(false)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,6 +1180,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return {
|
return {
|
||||||
placeholder,
|
placeholder,
|
||||||
bindings,
|
bindings,
|
||||||
|
shell,
|
||||||
visible,
|
visible,
|
||||||
options,
|
options,
|
||||||
selected: menu.selected,
|
selected: menu.selected,
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
onRows: props.onRows,
|
onRows: props.onRows,
|
||||||
onStatus: props.onStatus,
|
onStatus: props.onStatus,
|
||||||
})
|
})
|
||||||
|
const shell = createMemo(() => prompt() && composer.shell())
|
||||||
const menu = createMemo(() => prompt() && composer.visible())
|
const menu = createMemo(() => prompt() && composer.visible())
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -487,18 +488,20 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
>
|
>
|
||||||
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||||
{props.agent}
|
{shell() ? "Shell" : props.agent}
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
id="run-direct-footer-model"
|
|
||||||
fg={theme().text}
|
|
||||||
wrapMode="none"
|
|
||||||
truncate
|
|
||||||
flexGrow={1}
|
|
||||||
flexShrink={1}
|
|
||||||
>
|
|
||||||
{props.state().model}
|
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={!shell()}>
|
||||||
|
<text
|
||||||
|
id="run-direct-footer-model"
|
||||||
|
fg={theme().text}
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
flexGrow={1}
|
||||||
|
flexShrink={1}
|
||||||
|
>
|
||||||
|
{props.state().model}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -629,19 +632,30 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
>
|
>
|
||||||
<Show when={queue() > 0}>
|
<Show
|
||||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
when={shell()}
|
||||||
{queue()} queued
|
fallback={
|
||||||
</text>
|
<>
|
||||||
</Show>
|
<Show when={queue() > 0}>
|
||||||
<Show when={usage().length > 0}>
|
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||||
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
{queue()} queued
|
||||||
{usage()}
|
</text>
|
||||||
</text>
|
</Show>
|
||||||
</Show>
|
<Show when={usage().length > 0}>
|
||||||
<Show when={command().length > 0 && hints().command}>
|
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
||||||
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
|
{usage()}
|
||||||
{command()} <span style={{ fg: theme().muted }}>commands</span>
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={command().length > 0 && hints().command}>
|
||||||
|
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
|
||||||
|
{command()} <span style={{ fg: theme().muted }}>commands</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text id="run-direct-footer-hint-shell" fg={theme().text} wrapMode="none" truncate>
|
||||||
|
esc <span style={{ fg: theme().muted }}>exit shell mode</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -65,11 +65,12 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
|
|||||||
return {
|
return {
|
||||||
text: prompt.text,
|
text: prompt.text,
|
||||||
parts: structuredClone(prompt.parts),
|
parts: structuredClone(prompt.parts),
|
||||||
|
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
||||||
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
|
return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
|
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewCommand(prompt.text)) {
|
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||||
emit(
|
emit(
|
||||||
{
|
{
|
||||||
type: "queue",
|
type: "queue",
|
||||||
@@ -167,9 +167,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
if (prompt.mode !== "shell") {
|
||||||
input.trace?.write("ui.commit", commit)
|
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
||||||
input.footer.append(commit)
|
input.trace?.write("ui.commit", commit)
|
||||||
|
input.footer.append(commit)
|
||||||
|
}
|
||||||
input.onSend?.(prompt)
|
input.onSend?.(prompt)
|
||||||
|
|
||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
@@ -234,7 +236,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExitCommand(prompt.text)) {
|
if (prompt.mode !== "shell" && isExitCommand(prompt.text)) {
|
||||||
input.footer.close()
|
input.footer.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,7 +251,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
queue: state.queue.length,
|
queue: state.queue.length,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (isNewCommand(prompt.text)) {
|
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||||
drain()
|
drain()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,13 +61,20 @@ type SessionCommit = StreamCommit
|
|||||||
// - text: part ID → full accumulated text so far
|
// - text: part ID → full accumulated text so far
|
||||||
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
||||||
// - end: part IDs whose time.end has arrived (part is finished)
|
// - end: part IDs whose time.end has arrived (part is finished)
|
||||||
|
// - shell: shell call ID → chosen transcript source for direct shell calls
|
||||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||||
|
type ShellCall = {
|
||||||
|
source: "shell" | "tool"
|
||||||
|
command?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionData = {
|
export type SessionData = {
|
||||||
includeUserText: boolean
|
includeUserText: boolean
|
||||||
announced: boolean
|
announced: boolean
|
||||||
ids: Set<string>
|
ids: Set<string>
|
||||||
tools: Set<string>
|
tools: Set<string>
|
||||||
call: Map<string, Dict>
|
call: Map<string, Dict>
|
||||||
|
shell: Map<string, ShellCall>
|
||||||
permissions: PermissionRequest[]
|
permissions: PermissionRequest[]
|
||||||
questions: QuestionRequest[]
|
questions: QuestionRequest[]
|
||||||
role: Map<string, MessageRole>
|
role: Map<string, MessageRole>
|
||||||
@@ -104,6 +111,7 @@ export function createSessionData(
|
|||||||
ids: new Set(),
|
ids: new Set(),
|
||||||
tools: new Set(),
|
tools: new Set(),
|
||||||
call: new Map(),
|
call: new Map(),
|
||||||
|
shell: new Map(),
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
questions: [],
|
||||||
role: new Map(),
|
role: new Map(),
|
||||||
@@ -621,6 +629,87 @@ function toolCommit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellPartID(callID: string): string {
|
||||||
|
return `shell:${callID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall {
|
||||||
|
const current = data.shell.get(callID)
|
||||||
|
if (current) {
|
||||||
|
if (command && !current.command) {
|
||||||
|
current.command = command
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
source,
|
||||||
|
...(command ? { command } : {}),
|
||||||
|
} satisfies ShellCall
|
||||||
|
data.shell.set(callID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function bashCommand(part: ToolPart): string | undefined {
|
||||||
|
if (part.tool !== "bash") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = part.state.input
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = Reflect.get(input, "command")
|
||||||
|
return typeof command === "string" ? command : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellCommit(
|
||||||
|
input: {
|
||||||
|
callID: string
|
||||||
|
command: string
|
||||||
|
},
|
||||||
|
next: Pick<SessionCommit, "text" | "phase" | "toolState">,
|
||||||
|
): SessionCommit {
|
||||||
|
return {
|
||||||
|
kind: "tool",
|
||||||
|
source: "tool",
|
||||||
|
partID: shellPartID(input.callID),
|
||||||
|
tool: "bash",
|
||||||
|
shell: input,
|
||||||
|
...next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startShell(callID: string, command: string): SessionCommit {
|
||||||
|
return shellCommit(
|
||||||
|
{
|
||||||
|
callID,
|
||||||
|
command,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "running shell",
|
||||||
|
phase: "start",
|
||||||
|
toolState: "running",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneShell(callID: string, command: string, output: string): SessionCommit {
|
||||||
|
return shellCommit(
|
||||||
|
{
|
||||||
|
callID,
|
||||||
|
command,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: output,
|
||||||
|
phase: "progress",
|
||||||
|
toolState: "completed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function startTool(part: ToolPart): SessionCommit {
|
function startTool(part: ToolPart): SessionCommit {
|
||||||
return toolCommit(part, {
|
return toolCommit(part, {
|
||||||
text: toolStatus(part),
|
text: toolStatus(part),
|
||||||
@@ -681,6 +770,53 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
|||||||
const data = input.data
|
const data = input.data
|
||||||
const event = input.event
|
const event = input.event
|
||||||
|
|
||||||
|
if (event.type === "session.next.shell.started") {
|
||||||
|
if (event.properties.sessionID !== input.sessionID) {
|
||||||
|
return out(data, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = claimShell(data, event.properties.callID, "shell", event.properties.command)
|
||||||
|
if (shell.source !== "shell") {
|
||||||
|
return out(data, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
const partID = shellPartID(event.properties.callID)
|
||||||
|
if (data.ids.has(partID) || data.tools.has(partID)) {
|
||||||
|
return out(data, commits, patch({ status: "running shell" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
data.tools.add(partID)
|
||||||
|
commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command))
|
||||||
|
return out(data, commits, patch({ status: "running shell" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.next.shell.ended") {
|
||||||
|
if (event.properties.sessionID !== input.sessionID) {
|
||||||
|
return out(data, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = claimShell(data, event.properties.callID, "shell")
|
||||||
|
if (shell.source !== "shell") {
|
||||||
|
return out(data, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
const partID = shellPartID(event.properties.callID)
|
||||||
|
const seen = data.tools.has(partID)
|
||||||
|
const command = shell.command ?? ""
|
||||||
|
data.tools.delete(partID)
|
||||||
|
if (data.ids.has(partID)) {
|
||||||
|
return out(data, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen && command) {
|
||||||
|
commits.push(startShell(event.properties.callID, command))
|
||||||
|
}
|
||||||
|
|
||||||
|
data.ids.add(partID)
|
||||||
|
commits.push(doneShell(event.properties.callID, command, event.properties.output))
|
||||||
|
return out(data, commits)
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
if (event.properties.sessionID !== input.sessionID) {
|
if (event.properties.sessionID !== input.sessionID) {
|
||||||
return out(data, commits)
|
return out(data, commits)
|
||||||
@@ -782,6 +918,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
|||||||
|
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
const view = syncPermission(data, part) ?? syncQuestion(data, part)
|
const view = syncPermission(data, part) ?? syncQuestion(data, part)
|
||||||
|
if (part.tool === "bash" && part.callID) {
|
||||||
|
if (claimShell(data, part.callID, "tool", bashCommand(part)).source === "shell") {
|
||||||
|
return out(data, commits, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (part.state.status === "running") {
|
if (part.state.status === "running") {
|
||||||
if (data.ids.has(part.id)) {
|
if (data.ids.has(part.id)) {
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ function sid(event: Event): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
event.type === "session.next.shell.started" ||
|
||||||
|
event.type === "session.next.shell.ended" ||
|
||||||
event.type === "permission.asked" ||
|
event.type === "permission.asked" ||
|
||||||
event.type === "permission.replied" ||
|
event.type === "permission.replied" ||
|
||||||
event.type === "question.asked" ||
|
event.type === "question.asked" ||
|
||||||
@@ -513,6 +515,22 @@ function createLayer(input: StreamInput) {
|
|||||||
state.footerView = current
|
state.footerView = current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveShellAgent = Effect.fn("RunStreamTransport.resolveShellAgent")(function* (agent: string | undefined) {
|
||||||
|
if (agent) {
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = yield* Effect.promise(() =>
|
||||||
|
input.sdk.app.agents(input.directory ? { directory: input.directory } : undefined, { throwOnError: true }),
|
||||||
|
).pipe(Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => []))
|
||||||
|
const next = list.find((item) => item.mode !== "subagent" && item.hidden !== true)?.name
|
||||||
|
if (next) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return yield* Effect.fail(new Error("no primary agent available for shell mode"))
|
||||||
|
})
|
||||||
|
|
||||||
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
|
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
|
||||||
if (recovering.has(partID)) {
|
if (recovering.has(partID)) {
|
||||||
return
|
return
|
||||||
@@ -1005,7 +1023,46 @@ function createLayer(input: StreamInput) {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
const command = next.prompt.command
|
const command = next.prompt.command
|
||||||
const send = command
|
const send = next.prompt.mode === "shell"
|
||||||
|
? Effect.sync(() => {
|
||||||
|
input.trace?.write("send.shell", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
command: next.prompt.text,
|
||||||
|
})
|
||||||
|
}).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
resolveShellAgent(next.agent).pipe(
|
||||||
|
Effect.flatMap((agent) =>
|
||||||
|
Effect.promise(() =>
|
||||||
|
input.sdk.session.shell(
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent,
|
||||||
|
model: next.model,
|
||||||
|
command: next.prompt.text,
|
||||||
|
},
|
||||||
|
{ signal: turn.signal, throwOnError: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
|
Effect.tap(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
input.trace?.write("send.shell.ok", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
item.armed = true
|
||||||
|
item.live = true
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)),
|
||||||
|
Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)),
|
||||||
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
|
Effect.asVoid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: command
|
||||||
? Effect.sync(() => {
|
? Effect.sync(() => {
|
||||||
input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name })
|
input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name })
|
||||||
}).pipe(
|
}).pipe(
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch"
|
|||||||
import type { WriteTool } from "@/tool/write"
|
import type { WriteTool } from "@/tool/write"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
import * as Locale from "@/util/locale"
|
import * as Locale from "@/util/locale"
|
||||||
import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||||
|
|
||||||
export type ToolView = {
|
export type ToolView = {
|
||||||
output: boolean
|
output: boolean
|
||||||
@@ -626,6 +626,10 @@ function scrollBashStart(p: ToolProps<typeof BashTool>): string {
|
|||||||
const desc = p.input.description || "Shell"
|
const desc = p.input.description || "Shell"
|
||||||
const wd = p.input.workdir ?? ""
|
const wd = p.input.workdir ?? ""
|
||||||
const dir = wd && wd !== "." ? toolPath(wd) : ""
|
const dir = wd && wd !== "." ? toolPath(wd) : ""
|
||||||
|
if (cmd && desc === "Shell" && !dir) {
|
||||||
|
return `$ ${cmd}`
|
||||||
|
}
|
||||||
|
|
||||||
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
|
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
|
||||||
|
|
||||||
if (!cmd) {
|
if (!cmd) {
|
||||||
@@ -1248,7 +1252,7 @@ function frame(part: ToolPart): ToolFrame {
|
|||||||
raw: "",
|
raw: "",
|
||||||
name: part.tool,
|
name: part.tool,
|
||||||
input: dict(state.input),
|
input: dict(state.input),
|
||||||
meta: dict(state.metadata),
|
meta: "metadata" in part.state ? dict(part.state.metadata) : {},
|
||||||
state,
|
state,
|
||||||
status: text(state.status),
|
status: text(state.status),
|
||||||
error: text(state.error),
|
error: text(state.error),
|
||||||
@@ -1261,7 +1265,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
|||||||
raw,
|
raw,
|
||||||
name: commit.tool || commit.part?.tool || "tool",
|
name: commit.tool || commit.part?.tool || "tool",
|
||||||
input: dict(state.input),
|
input: dict(state.input),
|
||||||
meta: dict(state.metadata),
|
meta: commit.part?.state && "metadata" in commit.part.state ? dict(commit.part.state.metadata) : {},
|
||||||
state,
|
state,
|
||||||
status: commit.toolState ?? text(state.status),
|
status: commit.toolState ?? text(state.status),
|
||||||
error: (commit.toolError ?? "").trim(),
|
error: (commit.toolError ?? "").trim(),
|
||||||
@@ -1403,7 +1407,32 @@ function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undef
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellOutput(command: string, raw: string): string | undefined {
|
||||||
|
const body = stripAnsi(raw).replace(/^\n+/, "").replace(/\n+$/, "")
|
||||||
|
if (!body) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
return `\n${body}`
|
||||||
|
}
|
||||||
|
|
||||||
export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
|
export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
|
||||||
|
if (commit.shell) {
|
||||||
|
if (commit.phase === "start") {
|
||||||
|
return textBody(`$ ${commit.shell.command}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commit.phase === "progress") {
|
||||||
|
return textBody(shellOutput(commit.shell.command, raw) ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = toolFrame(commit, raw)
|
const ctx = toolFrame(commit, raw)
|
||||||
const view = toolView(ctx.name)
|
const view = toolView(ctx.name)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provide
|
|||||||
export type RunPrompt = {
|
export type RunPrompt = {
|
||||||
text: string
|
text: string
|
||||||
parts: RunPromptPart[]
|
parts: RunPromptPart[]
|
||||||
|
mode?: "shell"
|
||||||
command?: {
|
command?: {
|
||||||
name: string
|
name: string
|
||||||
arguments: string
|
arguments: string
|
||||||
@@ -302,6 +303,10 @@ export type StreamCommit = {
|
|||||||
interrupted?: boolean
|
interrupted?: boolean
|
||||||
toolState?: StreamToolState
|
toolState?: StreamToolState
|
||||||
toolError?: string
|
toolError?: string
|
||||||
|
shell?: {
|
||||||
|
callID: string
|
||||||
|
command: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The public contract between the stream transport / prompt queue and
|
// The public contract between the stream transport / prompt queue and
|
||||||
|
|||||||
@@ -359,6 +359,73 @@ describe("run entry body", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders command-only bash starts without the shell header", () => {
|
||||||
|
expect(
|
||||||
|
entryBody(
|
||||||
|
toolCommit({
|
||||||
|
tool: "bash",
|
||||||
|
phase: "start",
|
||||||
|
toolState: "running",
|
||||||
|
text: "running shell",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: {
|
||||||
|
command: "ls",
|
||||||
|
},
|
||||||
|
time: { start: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "$ ls",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders direct shell commits without a synthetic shell header", () => {
|
||||||
|
expect(
|
||||||
|
entryBody(
|
||||||
|
commit({
|
||||||
|
kind: "tool",
|
||||||
|
text: "running shell",
|
||||||
|
phase: "start",
|
||||||
|
source: "tool",
|
||||||
|
tool: "bash",
|
||||||
|
partID: "shell:call-1",
|
||||||
|
toolState: "running",
|
||||||
|
shell: {
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "$ pwd",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
entryBody(
|
||||||
|
commit({
|
||||||
|
kind: "tool",
|
||||||
|
text: "/tmp/demo\n",
|
||||||
|
phase: "progress",
|
||||||
|
source: "tool",
|
||||||
|
tool: "bash",
|
||||||
|
partID: "shell:call-1",
|
||||||
|
toolState: "completed",
|
||||||
|
shell: {
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "\n/tmp/demo",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("falls back to patch summary when apply_patch has no visible diff items", () => {
|
test("falls back to patch summary when apply_patch has no visible diff items", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ function footer() {
|
|||||||
api,
|
api,
|
||||||
events,
|
events,
|
||||||
commits,
|
commits,
|
||||||
submit(text: string) {
|
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||||
const next = { text, parts: [] as RunPrompt["parts"] }
|
const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
|
||||||
for (const fn of [...prompts]) {
|
for (const fn of [...prompts]) {
|
||||||
fn(next)
|
fn(next)
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,64 @@ describe("run runtime queue", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("shell mode submits /exit as a shell command", async () => {
|
||||||
|
const ui = footer()
|
||||||
|
const seen: RunPrompt[] = []
|
||||||
|
|
||||||
|
const task = runPromptQueue({
|
||||||
|
footer: ui.api,
|
||||||
|
run: async (input) => {
|
||||||
|
seen.push(input)
|
||||||
|
ui.api.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ui.submit("/exit", "shell")
|
||||||
|
await task
|
||||||
|
|
||||||
|
expect(seen).toEqual([{ text: "/exit", parts: [], mode: "shell" }])
|
||||||
|
expect(ui.commits).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shell mode submits /new instead of creating a session", async () => {
|
||||||
|
const ui = footer()
|
||||||
|
const seen: RunPrompt[] = []
|
||||||
|
let created = 0
|
||||||
|
|
||||||
|
const task = runPromptQueue({
|
||||||
|
footer: ui.api,
|
||||||
|
onNewSession: async () => {
|
||||||
|
created += 1
|
||||||
|
},
|
||||||
|
run: async (input) => {
|
||||||
|
seen.push(input)
|
||||||
|
ui.api.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ui.submit("/new", "shell")
|
||||||
|
await task
|
||||||
|
|
||||||
|
expect(created).toBe(0)
|
||||||
|
expect(seen).toEqual([{ text: "/new", parts: [], mode: "shell" }])
|
||||||
|
expect(ui.commits).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shell mode does not append a synthetic user row", async () => {
|
||||||
|
const ui = footer()
|
||||||
|
|
||||||
|
const task = runPromptQueue({
|
||||||
|
footer: ui.api,
|
||||||
|
run: async () => {
|
||||||
|
expect(ui.commits).toEqual([])
|
||||||
|
ui.api.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ui.submit("ls", "shell")
|
||||||
|
await task
|
||||||
|
})
|
||||||
|
|
||||||
test("preserves whitespace for initial input", async () => {
|
test("preserves whitespace for initial input", async () => {
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
|
|||||||
@@ -326,6 +326,190 @@ describe("run session data", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders direct shell mode from first-class shell events", () => {
|
||||||
|
let data = createSessionData()
|
||||||
|
const started = reduce(data, {
|
||||||
|
type: "session.next.shell.started",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 1,
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(started.commits).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "tool",
|
||||||
|
phase: "start",
|
||||||
|
partID: "shell:call-1",
|
||||||
|
tool: "bash",
|
||||||
|
shell: {
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
data = started.data
|
||||||
|
const ended = reduce(data, {
|
||||||
|
type: "session.next.shell.ended",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 2,
|
||||||
|
callID: "call-1",
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ended.commits).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "tool",
|
||||||
|
phase: "progress",
|
||||||
|
partID: "shell:call-1",
|
||||||
|
tool: "bash",
|
||||||
|
text: "/tmp/demo\n",
|
||||||
|
toolState: "completed",
|
||||||
|
shell: {
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppresses legacy bash part updates once shell events claim the call", () => {
|
||||||
|
let data = reduce(createSessionData(), {
|
||||||
|
type: "session.next.shell.started",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 1,
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}).data
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reduce(
|
||||||
|
data,
|
||||||
|
tool({
|
||||||
|
id: "tool-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "bash",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: {
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
time: { start: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).commits,
|
||||||
|
).toEqual([])
|
||||||
|
|
||||||
|
data = reduce(data, {
|
||||||
|
type: "session.next.shell.ended",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 2,
|
||||||
|
callID: "call-1",
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
},
|
||||||
|
}).data
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reduce(
|
||||||
|
data,
|
||||||
|
tool({
|
||||||
|
id: "tool-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "bash",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: {
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
title: "",
|
||||||
|
metadata: {
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).commits,
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppresses shell events when the legacy bash part claimed the call first", () => {
|
||||||
|
let data = reduce(
|
||||||
|
createSessionData(),
|
||||||
|
tool({
|
||||||
|
id: "tool-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "bash",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: {
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
time: { start: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).data
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reduce(data, {
|
||||||
|
type: "session.next.shell.started",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 1,
|
||||||
|
callID: "call-1",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}).commits,
|
||||||
|
).toEqual([])
|
||||||
|
|
||||||
|
data = reduce(
|
||||||
|
data,
|
||||||
|
tool({
|
||||||
|
id: "tool-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "bash",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: {
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
title: "",
|
||||||
|
metadata: {
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).data
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reduce(data, {
|
||||||
|
type: "session.next.shell.ended",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 2,
|
||||||
|
callID: "call-1",
|
||||||
|
output: "/tmp/demo\n",
|
||||||
|
},
|
||||||
|
}).commits,
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("synthesizes a glob start before an error when the running update is missed", () => {
|
test("synthesizes a glob start before an error when the running update is missed", () => {
|
||||||
expect(
|
expect(
|
||||||
reduce(
|
reduce(
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ describe("run subagent data", () => {
|
|||||||
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
|
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
|
||||||
"› Inspect footer tabs",
|
"› Inspect footer tabs",
|
||||||
"_Thinking:_ planning next steps",
|
"_Thinking:_ planning next steps",
|
||||||
"# Shell\n$ git status --short",
|
"$ git status --short",
|
||||||
"hello world",
|
"hello world",
|
||||||
])
|
])
|
||||||
expect(snapshot.permissions).toEqual([
|
expect(snapshot.permissions).toEqual([
|
||||||
|
|||||||
Reference in New Issue
Block a user