mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 01:57:38 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9a4baf9e |
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -14,12 +14,7 @@ type InboxRow = {
|
||||
sessionID: string
|
||||
time: { created: number }
|
||||
type: "user"
|
||||
payload: {
|
||||
text: string
|
||||
metadata?: Record<string, unknown>
|
||||
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
|
||||
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
|
||||
}
|
||||
payload: { text: string; metadata?: Record<string, unknown> }
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
@@ -34,7 +29,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
|
||||
const log: string[] = []
|
||||
let sequence = 0
|
||||
const emit = <Type extends OpenCodeEvent["type"]>(
|
||||
@@ -239,113 +234,6 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
expect(mock.log[0]).toBe("prompt:queue")
|
||||
})
|
||||
|
||||
test("Move Back cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(3)
|
||||
|
||||
const row = view.rows.filter({ hasText: "second queued prompt" })
|
||||
const actions = row.locator('[data-slot="session-queue-actions"] button')
|
||||
await expect(actions).toHaveCount(3)
|
||||
expect(
|
||||
await actions.evaluateAll((buttons) =>
|
||||
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
|
||||
),
|
||||
).toEqual(["Steer", "Move Back", "Remove"])
|
||||
const moveBack = row.getByRole("button", { name: "Move Back" })
|
||||
await expect(moveBack).toHaveText("")
|
||||
await expect(moveBack.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-undo-down")
|
||||
await moveBack.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
|
||||
await moveBack.click()
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
"first queued prompt",
|
||||
"third queued prompt",
|
||||
])
|
||||
await expect(view.input).toHaveText("second queued prompt")
|
||||
await expect(view.input).toBeFocused()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("Move Back preserves an existing draft and restores inline attachments", async ({ page }) => {
|
||||
const mock = createQueueMock(["queued with image"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "shot.png",
|
||||
},
|
||||
]
|
||||
const view = await openSession(page, mock)
|
||||
await view.input.fill("my draft")
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.input).toHaveText("my draft")
|
||||
expect(mock.changes).toEqual([])
|
||||
|
||||
await view.input.fill("")
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(view.input).toHaveText("queued with image")
|
||||
await expect(view.input).toBeFocused()
|
||||
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Move Back stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
|
||||
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(7)
|
||||
const row = view.rows.filter({ hasText: text })
|
||||
await row.getByRole("button", { name: "Move Back" }).hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
|
||||
await page.screenshot({ path: testInfo.outputPath("move-back-narrow-queue.png") })
|
||||
await row.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.rows).toHaveCount(6)
|
||||
await expect(view.input).toHaveText(text)
|
||||
await expect(view.input).toBeFocused()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Move Back preserves mentioned file and agent references on resubmission", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect @main.ts with @build"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
]
|
||||
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
|
||||
const view = await openSession(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.input).toHaveText("inspect @main.ts with @build")
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].files).toMatchObject([
|
||||
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
|
||||
])
|
||||
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
|
||||
})
|
||||
|
||||
test("Move Back does not discard hidden file context", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect this file"])
|
||||
mock.rows[0].payload.files = [
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
|
||||
]
|
||||
const view = await openSession(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
|
||||
await expect(view.rows).toHaveCount(1)
|
||||
await expect(view.input).toHaveText("")
|
||||
expect(mock.changes).toEqual([])
|
||||
})
|
||||
|
||||
for (const delivery of ["steer", "queue"] as const) {
|
||||
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
|
||||
page,
|
||||
|
||||
@@ -47,7 +47,6 @@ export type ComposerDelivery = "steer" | "queue"
|
||||
// is loaded in the editor.
|
||||
export type ComposerQueue = {
|
||||
count: Accessor<number>
|
||||
movingBack: Accessor<boolean>
|
||||
// Delivery a plain submit uses right now.
|
||||
delivery: Accessor<ComposerDelivery>
|
||||
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
|
||||
|
||||
@@ -168,7 +168,6 @@ function ComposerStory(props: {
|
||||
alternate: () => props.alternate,
|
||||
editing: () => undefined,
|
||||
confirmEdit() {},
|
||||
movingBack: () => false,
|
||||
cancelEdit() {},
|
||||
editFirst: () => false,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
readOnly?: boolean
|
||||
suggestionBoundary?: () => HTMLElement | undefined
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
@@ -28,7 +27,6 @@ export function Composer(props: {
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
readOnly={props.readOnly}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
|
||||
@@ -371,7 +371,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
if (queue?.movingBack()) return
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
|
||||
@@ -874,9 +874,6 @@ export const dict = {
|
||||
"session.queue.send": "Send",
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.moveBack": "Move Back",
|
||||
"session.queue.moveBackDraft": "Clear your draft before moving a prompt back",
|
||||
"session.queue.moveBackUnavailable": "Edit this prompt in the queue to preserve its file context",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
|
||||
@@ -175,18 +175,6 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" value={language.t("session.queue.moveBack")}>
|
||||
<IconButton
|
||||
data-action="session-queue-move-back"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="arrow-undo-down" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.moveBack")}
|
||||
onClick={() => props.queue.moveBack(props.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip placement="top" value={language.t("session.queue.remove")}>
|
||||
<IconButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { queuedPromptAttachments, queuedPromptMoveBackDraft, queuedPromptRows } from "./queue"
|
||||
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
|
||||
|
||||
const queued = [
|
||||
{
|
||||
@@ -104,43 +104,3 @@ describe("queuedPromptAttachments", () => {
|
||||
expect(queuedPromptAttachments(item)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("queuedPromptMoveBackDraft", () => {
|
||||
test("keeps full text, structured mentions, and inline images", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "inspect @main.ts with @build",
|
||||
files: [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
|
||||
],
|
||||
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
expect(queuedPromptMoveBackDraft(item)).toMatchObject([
|
||||
{ type: "text", content: "inspect " },
|
||||
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
|
||||
{ type: "text", content: " with " },
|
||||
{ type: "agent", content: "@build", name: "build" },
|
||||
{ type: "image", filename: "shot.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not drop hidden file context", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "inspect this",
|
||||
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
expect(queuedPromptMoveBackDraft(item)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
@@ -43,7 +42,6 @@ export function createSessionQueue(input: {
|
||||
mutationFn: async (
|
||||
change:
|
||||
| { type: "reorder"; inboxIDs: string[] }
|
||||
| { type: "move-back"; item: QueuedPrompt; prompt: Prompt }
|
||||
| {
|
||||
type: "edit"
|
||||
inboxIDs: string[]
|
||||
@@ -56,13 +54,6 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
) => {
|
||||
if (change.type === "reorder") return rewrite(change.inboxIDs)
|
||||
if (change.type === "move-back") {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
|
||||
input.draft.mode.set("normal")
|
||||
input.draft.set(change.prompt, promptLength(change.prompt))
|
||||
input.restoreFocus(promptLength(change.prompt))
|
||||
return
|
||||
}
|
||||
const replacement = await editedPromptInput(
|
||||
input.sessionID,
|
||||
location().directory,
|
||||
@@ -148,25 +139,6 @@ export function createSessionQueue(input: {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const moveBack = (id: string) => {
|
||||
if (mutation.isPending || state.editing) return
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
if (
|
||||
input.draft.current().some((part) => ("content" in part ? !!part.content.length : true)) ||
|
||||
input.draft.mode.current() !== "normal" ||
|
||||
input.draft.retry.current()
|
||||
) {
|
||||
showToast({ title: language.t("session.queue.moveBackDraft") })
|
||||
return
|
||||
}
|
||||
const prompt = queuedPromptMoveBackDraft(item)
|
||||
if (!prompt) {
|
||||
showToast({ title: language.t("session.queue.moveBackUnavailable") })
|
||||
return
|
||||
}
|
||||
mutation.mutate({ type: "move-back", item, prompt })
|
||||
}
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (mutation.isPending) return Promise.resolve()
|
||||
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
|
||||
@@ -254,11 +226,9 @@ export function createSessionQueue(input: {
|
||||
editFirst,
|
||||
rows,
|
||||
busy: () => mutation.isPending,
|
||||
movingBack: () => mutation.isPending && mutation.variables?.type === "move-back",
|
||||
working: input.working,
|
||||
steer,
|
||||
remove,
|
||||
moveBack,
|
||||
edit,
|
||||
reorder,
|
||||
}
|
||||
@@ -269,7 +239,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
|
||||
// The slice of the queue the panel renders and drives.
|
||||
export type SessionQueueView = Pick<
|
||||
SessionQueue,
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "moveBack" | "edit" | "reorder"
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
|
||||
>
|
||||
|
||||
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
|
||||
@@ -279,8 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments:
|
||||
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -318,88 +287,6 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
|
||||
]
|
||||
}
|
||||
|
||||
// Use the full model-visible text so comment notes and path references remain
|
||||
// in the draft. Convert mentioned files, agents, and skills back into editor
|
||||
// parts; a detached draft cannot represent non-mentioned file context.
|
||||
export function queuedPromptMoveBackDraft(item: QueuedPrompt): Prompt | undefined {
|
||||
if (
|
||||
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
|
||||
item.payload.agents?.some((agent) => !agent.mention) ||
|
||||
item.payload.skills?.some((skill) => !skill.mention)
|
||||
)
|
||||
return
|
||||
const text = item.payload.text
|
||||
const references = [
|
||||
...(item.payload.files ?? []).flatMap((file) =>
|
||||
file.mention
|
||||
? [
|
||||
{
|
||||
type: "file" as const,
|
||||
content: file.mention.text,
|
||||
start: file.mention.start,
|
||||
end: file.mention.end,
|
||||
path: file.name ?? file.mention.text.replace(/^@/, ""),
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
url: `data:${file.mime};base64,${file.data}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.agents ?? []).flatMap((agent) =>
|
||||
agent.mention
|
||||
? [
|
||||
{
|
||||
type: "agent" as const,
|
||||
content: agent.mention.text,
|
||||
start: agent.mention.start,
|
||||
end: agent.mention.end,
|
||||
name: agent.name,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.skills ?? []).flatMap((skill) =>
|
||||
skill.mention
|
||||
? [
|
||||
{
|
||||
type: "skill" as const,
|
||||
content: skill.mention.text,
|
||||
start: skill.mention.start,
|
||||
end: skill.mention.end,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
].sort((left, right) => left.start - right.start)
|
||||
if (
|
||||
references.some(
|
||||
(part, index) =>
|
||||
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
|
||||
)
|
||||
)
|
||||
return
|
||||
const parts: Prompt = references.flatMap((part, index) => {
|
||||
const start = references[index - 1]?.end ?? 0
|
||||
return [
|
||||
...(part.start > start
|
||||
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
|
||||
: []),
|
||||
part,
|
||||
]
|
||||
})
|
||||
const start = references.at(-1)?.end ?? 0
|
||||
return [
|
||||
...parts,
|
||||
...(text.length > start || !parts.length
|
||||
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
|
||||
: []),
|
||||
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
|
||||
]
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
return !file.mention && file.source.type === "inline"
|
||||
}
|
||||
|
||||
@@ -224,12 +224,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.movingBack()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
|
||||
import {
|
||||
BoxRenderable,
|
||||
DiffRenderable,
|
||||
LineNumberRenderable,
|
||||
type ColorInput,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createMemo, For, Show, splitProps } from "solid-js"
|
||||
import { splitPatchHunks } from "../util/diff"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { splitAddedPatch, splitPatchHunks, type AddedPatchChunk } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export interface PatchDiffRef {
|
||||
readonly hunks: () => readonly DiffRenderable[]
|
||||
readonly hunks: () => readonly (DiffRenderable | BoxRenderable)[]
|
||||
}
|
||||
|
||||
const VIRTUAL_CHUNK_LINES = 128
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
|
||||
diff: string
|
||||
hunkFg: ColorInput
|
||||
lineNumberBg: ColorInput
|
||||
ref?: (value: PatchDiffRef) => void
|
||||
virtualScroll?: () => ScrollBoxRenderable | undefined
|
||||
viewportWidth?: number
|
||||
}
|
||||
|
||||
export function PatchDiff(props: Props) {
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
|
||||
const [local, diffProps] = splitProps(props, [
|
||||
"diff",
|
||||
"hunkFg",
|
||||
"lineNumberBg",
|
||||
"ref",
|
||||
"virtualScroll",
|
||||
"viewportWidth",
|
||||
])
|
||||
const hunks = createMemo(() => splitPatchHunks(local.diff))
|
||||
const chunks = createMemo(() => local.virtualScroll && splitAddedPatch(local.diff, VIRTUAL_CHUNK_LINES))
|
||||
const nodes = new Map<number, DiffRenderable>()
|
||||
let virtualRoot: BoxRenderable | undefined
|
||||
local.ref?.({
|
||||
hunks: () =>
|
||||
[...nodes.entries()]
|
||||
hunks: () => {
|
||||
if (chunks()) return virtualRoot && !virtualRoot.isDestroyed ? [virtualRoot] : []
|
||||
return [...nodes.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, node]) => node)
|
||||
.filter((node) => !node.isDestroyed),
|
||||
.filter((node) => !node.isDestroyed)
|
||||
},
|
||||
})
|
||||
const syncGutters = (attempt = 0) => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -55,29 +77,130 @@ export function PatchDiff(props: Props) {
|
||||
}
|
||||
const register = (index: number, node: DiffRenderable) => {
|
||||
nodes.set(index, node)
|
||||
onCleanup(() => nodes.delete(index))
|
||||
syncGutters()
|
||||
}
|
||||
|
||||
return (
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
<Show
|
||||
when={chunks()}
|
||||
fallback={
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
{(items) => (
|
||||
<VirtualAddedPatch
|
||||
chunks={items()}
|
||||
width={local.viewportWidth ?? 80}
|
||||
scroll={local.virtualScroll!}
|
||||
diffProps={diffProps}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
register={register}
|
||||
registerRoot={(root) => (virtualRoot = root)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function VirtualAddedPatch(props: {
|
||||
chunks: readonly AddedPatchChunk[]
|
||||
width: number
|
||||
scroll: () => ScrollBoxRenderable | undefined
|
||||
diffProps: Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref">
|
||||
lineNumberBg: ColorInput
|
||||
register: (index: number, node: DiffRenderable) => void
|
||||
registerRoot: (root: BoxRenderable) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const [visible, setVisible] = createSignal(0)
|
||||
const [measured, setMeasured] = createSignal<ReadonlyMap<number, number>>(new Map())
|
||||
createEffect(() => {
|
||||
props.width
|
||||
props.chunks
|
||||
setMeasured(new Map())
|
||||
})
|
||||
// Offscreen chunks need heights for scroll jumps before OpenTUI has measured them.
|
||||
// Replace those estimates with actual rendered heights as chunks enter the viewport.
|
||||
const estimates = createMemo(() => {
|
||||
const codeWidth = Math.max(
|
||||
1,
|
||||
props.width - String(props.chunks.reduce((count, chunk) => count + chunk.rows, 0)).length - 5,
|
||||
)
|
||||
return props.chunks.map((chunk) =>
|
||||
chunk.lines.reduce((height, line) => height + Math.max(1, Math.ceil(stringWidth(line.slice(1)) / codeWidth)), 0),
|
||||
)
|
||||
})
|
||||
const heights = createMemo(() => estimates().map((estimate, index) => measured().get(index) ?? estimate))
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
ref={(root: BoxRenderable) => {
|
||||
props.registerRoot(root)
|
||||
root.onLifecyclePass = () => {
|
||||
const scroll = props.scroll()
|
||||
if (!scroll) return
|
||||
// ScrollBox's scroll position is not a Solid signal; observe it during the render pass.
|
||||
const offset = root.y - scroll.content.y
|
||||
const top = scroll.scrollTop - offset
|
||||
const sizes = heights()
|
||||
if (top + scroll.viewport.height < 0 || top > sizes.reduce((sum, height) => sum + height, 0)) {
|
||||
setVisible(-1)
|
||||
return
|
||||
}
|
||||
let position = 0
|
||||
const index = sizes.findIndex((height) => (position += height) > top)
|
||||
setVisible(index < 0 ? sizes.length - 1 : index)
|
||||
}
|
||||
renderer.registerLifecyclePass(root)
|
||||
onCleanup(() => renderer.unregisterLifecyclePass(root))
|
||||
}}
|
||||
>
|
||||
<For each={props.chunks}>
|
||||
{(chunk, index) => (
|
||||
<Show
|
||||
when={visible() >= 0 && Math.abs(index() - visible()) <= 2}
|
||||
fallback={<box height={heights()[index()]} />}
|
||||
>
|
||||
<diff
|
||||
{...props.diffProps}
|
||||
ref={(node: DiffRenderable) => {
|
||||
props.register(index(), node)
|
||||
node.onSizeChange = () => {
|
||||
if (node.height <= 0 || measured().get(index()) === node.height) return
|
||||
const scroll = props.scroll()
|
||||
const atEnd = scroll && scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height - 1
|
||||
setMeasured((known) => new Map(known).set(index(), node.height))
|
||||
// Keep G pinned to the end when a newly mounted chunk changes total height.
|
||||
if (atEnd) requestAnimationFrame(() => scroll.scrollTo(Infinity))
|
||||
}
|
||||
}}
|
||||
diff={chunk.patch}
|
||||
lineNumberBg={props.lineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ export type PromptProps = {
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
current: PromptInfo
|
||||
setMode(mode: "normal" | "shell"): void
|
||||
set(prompt: PromptInfo): void
|
||||
reset(): void
|
||||
blur(): void
|
||||
@@ -680,9 +679,6 @@ export function Prompt(props: PromptProps) {
|
||||
blur() {
|
||||
input.blur()
|
||||
},
|
||||
setMode(mode) {
|
||||
setStore("mode", mode)
|
||||
},
|
||||
set(prompt) {
|
||||
input.setText(prompt.text)
|
||||
setStore("prompt", prompt)
|
||||
|
||||
@@ -129,7 +129,6 @@ export const Definitions = {
|
||||
"session.aside": keybind("none", "Ask a side question"),
|
||||
"session.cd": keybind("none", "Change working directory"),
|
||||
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
|
||||
"queued_prompt.move_back": keybind("ctrl+m", "Move queued prompt back to input"),
|
||||
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
|
||||
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
|
||||
"session.child.first": keybind("down", "Toggle subagent picker"),
|
||||
|
||||
@@ -1024,6 +1024,12 @@ export function DiffViewerContent(props: {
|
||||
onCleanup(() => patchDiffByFileIndex.delete(entry.fileIndex))
|
||||
}}
|
||||
diff={patch()}
|
||||
virtualScroll={
|
||||
entry.file.status === "added" && entry.file.additions > 1000
|
||||
? () => scroll
|
||||
: undefined
|
||||
}
|
||||
viewportWidth={patchPaneWidth()}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={entry.file.status === "modified" ? view() : "unified"}
|
||||
filetype={filetype(entry.file.file)}
|
||||
|
||||
@@ -539,6 +539,7 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (item.action === "subagent") {
|
||||
props.onSubagent()
|
||||
return
|
||||
@@ -948,7 +949,6 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onSelect: (prompt: FooterQueuedPrompt) => void
|
||||
onMoveBack: (prompt: FooterQueuedPrompt) => void
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
@@ -970,21 +970,10 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
onRows: props.onRows,
|
||||
})
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const moveBackShortcut = () => monoShortcut(shortcuts.get("queued_prompt.move_back") ?? "", props.mono ?? false)
|
||||
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "queued_prompt.move_back",
|
||||
title: "Move back",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
const item = controller.items()[controller.menu.selected()]
|
||||
if (!item) return false
|
||||
props.onMoveBack(item.prompt)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "queued_prompt.delete",
|
||||
title: "Delete pending prompt",
|
||||
@@ -1012,7 +1001,6 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
hint={[
|
||||
controller.items()[controller.menu.selected()]?.prompt.delivery === "steer" ? "enter queue" : "enter steer",
|
||||
deleteShortcut() ? `${deleteShortcut()} delete` : undefined,
|
||||
moveBackShortcut() ? `${moveBackShortcut()} move back` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
|
||||
@@ -160,7 +160,6 @@ export type PromptState = {
|
||||
onPaste: (event: PasteEvent) => Promise<void>
|
||||
onContentChange: () => void
|
||||
onSizeChange: () => void
|
||||
current: () => RunPrompt
|
||||
replacePrompt: (prompt: RunPrompt) => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
@@ -1545,10 +1544,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
scheduleRows()
|
||||
},
|
||||
onSizeChange: scheduleRows,
|
||||
current: () => {
|
||||
syncDraft()
|
||||
return promptCopy(draft)
|
||||
},
|
||||
replacePrompt: restore,
|
||||
bind,
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
const runQueuedAction = createSingleFlight<string>()
|
||||
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string, failureLabel?: string) => {
|
||||
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string) => {
|
||||
const run = props.onQueuedPromptAction
|
||||
if (!run) return false
|
||||
const result = await runQueuedAction(inboxID, async () => {
|
||||
@@ -329,9 +329,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return true
|
||||
props.onStatus(
|
||||
`failed to ${failureLabel ?? (action === "cancel" ? "delete" : action)} pending prompt: ${errorMessage(error)}`,
|
||||
)
|
||||
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} pending prompt: ${errorMessage(error)}`)
|
||||
return false
|
||||
})
|
||||
return result ?? false
|
||||
@@ -797,16 +795,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
)
|
||||
closePanel()
|
||||
}}
|
||||
onMoveBack={async (item) => {
|
||||
const current = composer.current()
|
||||
if (current.text.length || current.parts.length) {
|
||||
props.onStatus("clear your draft before moving a prompt back")
|
||||
return
|
||||
}
|
||||
if (!(await queuedPromptAction("cancel", item.messageID, "move back"))) return
|
||||
closePanel()
|
||||
composer.replacePrompt({ ...item.prompt, messageID: undefined })
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
void queuedPromptAction("cancel", item.messageID)
|
||||
}}
|
||||
|
||||
@@ -211,9 +211,7 @@ export function Session(props: {
|
||||
)
|
||||
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||
const queuedPrompts = createMemo(() =>
|
||||
pendingUsers().flatMap((item) =>
|
||||
item.delivery === "queue" ? [{ id: item.id, text: item.payload.text, payload: item.payload }] : [],
|
||||
),
|
||||
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
|
||||
)
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
@@ -603,7 +601,7 @@ export function Session(props: {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const runPendingAction = createSingleFlight<string>()
|
||||
const mutatePending = async (action: PendingAction, inboxID: string, failureLabel?: string) => {
|
||||
const mutatePending = async (action: PendingAction, inboxID: string) => {
|
||||
const result = await runPendingAction(inboxID, async () => {
|
||||
const request =
|
||||
action === "steer"
|
||||
@@ -616,7 +614,7 @@ export function Session(props: {
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return true
|
||||
const label = failureLabel ?? (action === "cancel" ? "delete" : action)
|
||||
const label = action === "cancel" ? "delete" : action
|
||||
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
})
|
||||
@@ -647,34 +645,6 @@ export function Session(props: {
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "queued_prompt.move_back",
|
||||
title: "move back",
|
||||
side: "right",
|
||||
onTrigger: (option) => {
|
||||
const target = prompt()
|
||||
const queued = queuedPrompts().find((item) => item.id === option.value)
|
||||
if (!target || !queued) return
|
||||
const current = target.current
|
||||
if (
|
||||
current.text.length ||
|
||||
current.files?.length ||
|
||||
current.agents?.length ||
|
||||
current.skills?.length ||
|
||||
current.pasted.length
|
||||
) {
|
||||
toast.show({ message: "Clear or stash your draft before moving a prompt back", variant: "error" })
|
||||
return
|
||||
}
|
||||
void mutatePending("cancel", queued.id, "move back").then((moved) => {
|
||||
if (!moved) return
|
||||
target.setMode("normal")
|
||||
target.set({ ...projectedPromptInput(queued.payload), pasted: [] })
|
||||
dialog.clear()
|
||||
target.focus()
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={[{ title: "steer", label: "enter" }]}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,35 @@ export interface PatchHunk {
|
||||
readonly rows?: number
|
||||
}
|
||||
|
||||
export interface AddedPatchChunk {
|
||||
readonly patch: string
|
||||
readonly lines: readonly string[]
|
||||
readonly rows: number
|
||||
}
|
||||
|
||||
/** Only a complete, single-hunk new-file patch can be split without changing diff semantics. */
|
||||
export function splitAddedPatch(patch: string, size: number): AddedPatchChunk[] | undefined {
|
||||
const header = /^@@ -0,0 \+1,(\d+) @@[^\n]*\n/m.exec(patch)
|
||||
if (!header) return
|
||||
const count = Number(header[1])
|
||||
const lines = patch
|
||||
.slice(header.index + header[0].length)
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
const marker = lines.at(-1)?.startsWith("\\ No newline at end of file") ? lines.pop() : undefined
|
||||
if (lines.length !== count || lines.some((line) => !line.startsWith("+"))) return
|
||||
const prefix = patch.slice(0, header.index)
|
||||
return Array.from({ length: Math.ceil(count / size) }, (_, index) => {
|
||||
const start = index * size
|
||||
const slice = lines.slice(start, start + size)
|
||||
return {
|
||||
patch: `${prefix}@@ -0,0 +${start + 1},${slice.length} @@\n${slice.join("\n")}${marker && start + size >= count ? `\n${marker}` : ""}`,
|
||||
lines: slice,
|
||||
rows: slice.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function splitPatchHunks(patch: string): PatchHunk[] {
|
||||
const starts = [...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm)].map((match) => match.index)
|
||||
if (starts.length <= 1) return [{ patch }]
|
||||
|
||||
@@ -2058,6 +2058,98 @@ const manyDiffs = Array.from({ length: 40 }, (_, index) => ({
|
||||
file: `file${String(index).padStart(2, "0")}.txt`,
|
||||
}))
|
||||
|
||||
test.each([80, 160])("virtualizes a large added file at %i columns without losing its end", async (width) => {
|
||||
const lines = [
|
||||
"+{",
|
||||
...Array.from(
|
||||
{ length: 2500 },
|
||||
(_, index) =>
|
||||
`+ "row-${String(index).padStart(4, "0")}": "${"value".repeat(index === 777 ? 2000 : index % 7 === 0 ? 24 : 1)}"${index === 2499 ? "" : ","}`,
|
||||
),
|
||||
"+}",
|
||||
]
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "snapshot.json",
|
||||
status: "added",
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
patch: `diff --git a/snapshot.json b/snapshot.json\nnew file mode 100644\n--- /dev/null\n+++ b/snapshot.json\n@@ -0,0 +1,${lines.length} @@\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
{ width, height: 24 },
|
||||
)
|
||||
try {
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-0000")
|
||||
expect(
|
||||
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
|
||||
).toBeLessThan(900)
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
if (!viewer.app.captureCharFrame().includes("row-2499")) {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("row-2499"))
|
||||
}
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-2499")
|
||||
expect(
|
||||
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
|
||||
).toBeLessThan(900)
|
||||
viewer.commands.get("diff.first")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-0000")
|
||||
viewer.app.resize(width === 80 ? 160 : 80, 20)
|
||||
await viewer.app.flush()
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-2499")
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("file navigation and review still work after a virtualized patch", async () => {
|
||||
const additions = Array.from({ length: 2200 }, (_, index) => `+added line ${index}`)
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "a-large.txt",
|
||||
status: "added",
|
||||
additions: additions.length,
|
||||
deletions: 0,
|
||||
patch: `--- /dev/null\n+++ b/a-large.txt\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
|
||||
},
|
||||
{ ...hunkDiff[0], file: "b-small.txt" },
|
||||
],
|
||||
{ width: 160, height: 24 },
|
||||
)
|
||||
try {
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
scroll.scrollTo(900)
|
||||
await viewer.app.flush()
|
||||
viewer.commands.get("diff.previous_hunk")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("added line 0")
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
|
||||
viewer.commands.get("diff.next_file")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
|
||||
expect(viewer.app.captureCharFrame()).toContain("const first")
|
||||
viewer.commands.get("diff.previous_file")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("a-large.txt")
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("added line 0")
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("added line 0")
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function findScrollBox(root: Renderable, patches = true): ScrollBoxRenderable | undefined {
|
||||
const node = root.findDescendantById(patches ? "diff-patches" : "diff-files")
|
||||
return node instanceof ScrollBoxRenderable ? node : undefined
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
BoxRenderable,
|
||||
ImageRenderable,
|
||||
RGBA,
|
||||
TextareaRenderable,
|
||||
type CliRenderer,
|
||||
type RootRenderable,
|
||||
} from "@opentui/core"
|
||||
import { BoxRenderable, ImageRenderable, RGBA, type CliRenderer, type RootRenderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
@@ -1275,7 +1268,7 @@ test.each(["queue", "steer"] as const)("direct footer toggles and deletes pendin
|
||||
expect(frame).toContain("Pending prompts")
|
||||
expect(frame).toContain("follow up")
|
||||
expect(frame).toContain(delivery === "queue" ? "queued" : "steering")
|
||||
expect(frame).toContain(`enter ${delivery === "queue" ? "steer" : "queue"} · ctrl+d delete · ctrl+m move back`)
|
||||
expect(frame).toContain(`enter ${delivery === "queue" ? "steer" : "queue"} · ctrl+d delete`)
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
@@ -1301,141 +1294,6 @@ test.each(["queue", "steer"] as const)("direct footer toggles and deletes pendin
|
||||
}
|
||||
})
|
||||
|
||||
test("move back restores a pending prompt without overwriting a draft", async () => {
|
||||
const actions: string[] = []
|
||||
const statuses: string[] = []
|
||||
const submitted: RunPrompt[] = []
|
||||
const queued: FooterQueuedPrompt = {
|
||||
messageID: "m-1",
|
||||
prompt: {
|
||||
messageID: "m-1",
|
||||
text: "look at main.ts",
|
||||
parts: [],
|
||||
},
|
||||
delivery: "queue",
|
||||
}
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [queued],
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onSubmit: (prompt) => {
|
||||
submitted.push(prompt)
|
||||
return true
|
||||
},
|
||||
onQueuedPromptAction: async (action, inboxID) => {
|
||||
actions.push(`${action}:${inboxID}`)
|
||||
app.setQueuedPrompts([])
|
||||
},
|
||||
})
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("existing draft")
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressKey("q")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
expect(statuses.at(-1)).toBe("clear your draft before moving a prompt back")
|
||||
expect(actions).toEqual([])
|
||||
app.mockInput.pressKey("ESCAPE")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("existing draft")
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressKey("q")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
await Bun.sleep(0)
|
||||
await app.renderOnce()
|
||||
expect(actions).toEqual(["cancel:m-1"])
|
||||
expect(app.captureCharFrame()).toContain("look at main.ts")
|
||||
expect(app.captureCharFrame()).not.toContain("Pending prompts")
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(0)
|
||||
await app.renderOnce()
|
||||
expect(submitted).toMatchObject([{ text: "look at main.ts" }])
|
||||
expect(submitted[0].messageID).toBeUndefined()
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("move back leaves the queue and input alone when cancellation fails", async () => {
|
||||
const statuses: string[] = []
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [{ messageID: "m-1", prompt: { text: "still queued", parts: [] }, delivery: "queue" }],
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onQueuedPromptAction: async () => {
|
||||
throw new Error("cancel failed")
|
||||
},
|
||||
})
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressKey("q")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
await Bun.sleep(0)
|
||||
await app.renderOnce()
|
||||
expect(statuses.at(-1)).toContain("failed to move back pending prompt: cancel failed")
|
||||
expect(app.captureCharFrame()).toContain("Pending prompts")
|
||||
app.mockInput.pressKey("ESCAPE")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("still queued")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("move back retains mentioned files when the prompt is sent again", async () => {
|
||||
const submitted: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [
|
||||
{
|
||||
messageID: "m-1",
|
||||
delivery: "queue",
|
||||
prompt: {
|
||||
messageID: "m-1",
|
||||
text: "inspect @src/main.ts please",
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///src/main.ts",
|
||||
filename: "main.ts",
|
||||
source: { type: "file", path: "src/main.ts", text: { start: 8, end: 20, value: "@src/main.ts" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
onQueuedPromptAction: async () => {
|
||||
app.setQueuedPrompts([])
|
||||
},
|
||||
onSubmit: (prompt) => {
|
||||
submitted.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressKey("q")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
await Bun.sleep(0)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("inspect @src/main.ts please")
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(0)
|
||||
expect(submitted).toMatchObject([
|
||||
{ text: "inspect @src/main.ts please", parts: [{ type: "file", url: "file:///src/main.ts" }] },
|
||||
])
|
||||
expect(submitted[0].messageID).toBeUndefined()
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
|
||||
const steered: string[] = []
|
||||
const app = await renderFooter({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { splitAddedPatch } from "../../src/util/diff"
|
||||
|
||||
test("splits a complete new-file patch into independently numbered chunks", () => {
|
||||
const patch = `diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
+one
|
||||
+++value beginning with plus signs
|
||||
+three
|
||||
+four
|
||||
+five`
|
||||
const chunks = splitAddedPatch(patch, 2)!
|
||||
expect(chunks.map((chunk) => chunk.rows)).toEqual([2, 2, 1])
|
||||
expect(chunks.map((chunk) => chunk.patch.match(/@@ -0,0 \+(\d+),(\d+) @@/)?.slice(1))).toEqual([
|
||||
["1", "2"],
|
||||
["3", "2"],
|
||||
["5", "1"],
|
||||
])
|
||||
expect(chunks.flatMap((chunk) => chunk.lines)).toEqual([
|
||||
"+one",
|
||||
"+++value beginning with plus signs",
|
||||
"+three",
|
||||
"+four",
|
||||
"+five",
|
||||
])
|
||||
expect(chunks.every((chunk) => chunk.patch.startsWith("diff --git a/new.txt b/new.txt"))).toBe(true)
|
||||
})
|
||||
|
||||
test("retains a missing-final-newline marker only on the last chunk", () => {
|
||||
const patch = `--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,3 @@\n+one\n+two\n+three\n\\ No newline at end of file\n`
|
||||
const chunks = splitAddedPatch(patch, 2)!
|
||||
expect(chunks).toHaveLength(2)
|
||||
expect(chunks[0].patch).not.toContain("No newline")
|
||||
expect(chunks[1].patch).toContain("+three\n\\ No newline at end of file")
|
||||
})
|
||||
|
||||
test("does not split partial or mixed patches", () => {
|
||||
expect(splitAddedPatch("@@ -1 +1 @@\n-before\n+after", 2)).toBeUndefined()
|
||||
expect(splitAddedPatch("@@ -0,0 +1,3 @@\n+one\n+two", 2)).toBeUndefined()
|
||||
expect(splitAddedPatch("@@ -0,0 +1,2 @@\n+one\n two", 2)).toBeUndefined()
|
||||
})
|
||||
@@ -197,7 +197,6 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
|
||||
| `session.aside` | `none` | Ask a side question |
|
||||
| `session.cd` | `none` | Change working directory |
|
||||
| `session.queued_prompts` | `<leader>q` | Manage queued prompts |
|
||||
| `queued_prompt.move_back` | `ctrl+m` | Move queued prompt back to input |
|
||||
| `queued_prompt.delete` | `ctrl+d` | Delete queued prompt |
|
||||
| `session.toggle.exploration_grouping` | `none` | Toggle related tool call grouping |
|
||||
| `session.child.first` | `down` | Toggle subagent picker |
|
||||
|
||||
Reference in New Issue
Block a user