Merge branch 'dev' into worktree-audit-effect-services
commit
a96edcf39b
|
|
@ -13,6 +13,7 @@ import {
|
|||
sessionComposerDockSelector,
|
||||
sessionTodoToggleButtonSelector,
|
||||
} from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||
|
|
@ -310,6 +311,73 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
|||
})
|
||||
})
|
||||
|
||||
test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(second).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Space")
|
||||
await page.keyboard.press(`${modKey}+Enter`)
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question escape", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
|
|
|||
|
|
@ -1344,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
|
|||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("adds file parts for @mentions inside comment text", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
messageID: "msg_comment_mentions",
|
||||
sessionID: "ses_comment_mentions",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
|||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
|
|
@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(url)) return []
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(path),
|
||||
} satisfies PromptRequestPart,
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
|
|
@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
...mentions,
|
||||
]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,9 @@ export default function Page() {
|
|||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
commentMentions={{
|
||||
items: file.searchFilesAndDirectories,
|
||||
}}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
|
|
|
|||
|
|
@ -29,16 +29,20 @@ function Option(props: {
|
|||
label: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
ref?: (el: HTMLButtonElement) => void
|
||||
onFocus?: VoidFunction
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={props.ref}
|
||||
data-slot="question-option"
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
disabled={props.disabled}
|
||||
onFocus={props.onFocus}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
|
|
@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
|
|
@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||
}
|
||||
|
||||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
const focus = (i: number) => {
|
||||
const next = clamp(i)
|
||||
setStore("focus", next)
|
||||
if (store.editing) return
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
focusFrame = requestAnimationFrame(() => {
|
||||
focusFrame = undefined
|
||||
const el = next === options().length ? customRef : optsRef[next]
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let raf: number | undefined
|
||||
const update = () => {
|
||||
|
|
@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
observer.disconnect()
|
||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
cache.set(props.request.id, {
|
||||
tab: store.tab,
|
||||
|
|
@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
|
||||
const customToggle = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
|
|
@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
const value = input().trim()
|
||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const move = (step: number) => {
|
||||
if (store.editing || sending()) return
|
||||
focus(store.focus + step)
|
||||
}
|
||||
|
||||
const nav = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
void reject()
|
||||
return
|
||||
}
|
||||
|
||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||
if (mod && event.key === "Enter") {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const target =
|
||||
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
||||
if (store.editing) return
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
focus(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "End") return
|
||||
event.preventDefault()
|
||||
focus(count() - 1)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (sending()) return
|
||||
|
||||
|
|
@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
const opt = options()[optIndex]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
|
|
@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
const commitCustom = () => {
|
||||
setStore("editing", false)
|
||||
customUpdate(input())
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
|
|
@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
return
|
||||
}
|
||||
|
||||
setStore("tab", store.tab + 1)
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
if (sending()) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
|
|
@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
|
|
@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
|
|
@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
|
|||
comments: fileComments,
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
draftKey: () => path() ?? props.tab,
|
||||
mention: {
|
||||
items: file.searchFilesAndDirectories,
|
||||
},
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
|
|||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement) => void
|
||||
commentMentions?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
classes?: {
|
||||
root?: string
|
||||
header?: string
|
||||
|
|
@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
lineCommentActions={props.lineCommentActions}
|
||||
lineCommentMention={props.commentMentions}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { app } from "electron"
|
|||
import treeKill from "tree-kill"
|
||||
|
||||
import { WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { store } from "./store"
|
||||
|
||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
||||
|
|
@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|||
const base = Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
const envs = {
|
||||
const env = {
|
||||
...base,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
|
|
@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
...extraEnv,
|
||||
}
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
|
||||
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs)
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
|
||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
env: envs,
|
||||
|
|
@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
|
|||
return false
|
||||
}
|
||||
|
||||
function buildCommand(args: string, env: Record<string, string>) {
|
||||
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
|
||||
if (process.platform === "win32" && isWslEnabled()) {
|
||||
console.log(`[cli] Using WSL mode`)
|
||||
const version = app.getVersion()
|
||||
|
|
@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
|
|||
}
|
||||
|
||||
const sidecar = getSidecarPath()
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
|
||||
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
|
||||
const user = shell || getUserShell()
|
||||
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
||||
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
||||
}
|
||||
|
||||
function envPrefix(env: Record<string, string>) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
|
||||
|
||||
describe("shell env", () => {
|
||||
test("parseShellEnv supports null-delimited pairs", () => {
|
||||
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin")
|
||||
expect(env.FOO).toBe("bar=baz")
|
||||
})
|
||||
|
||||
test("parseShellEnv ignores invalid entries", () => {
|
||||
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
|
||||
|
||||
expect(Object.keys(env).length).toBe(1)
|
||||
expect(env.OK).toBe("1")
|
||||
})
|
||||
|
||||
test("mergeShellEnv keeps explicit overrides", () => {
|
||||
const env = mergeShellEnv(
|
||||
{
|
||||
PATH: "/shell/path",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
{
|
||||
PATH: "/desktop/path",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
expect(env.PATH).toBe("/desktop/path")
|
||||
expect(env.HOME).toBe("/tmp/home")
|
||||
expect(env.OPENCODE_CLIENT).toBe("desktop")
|
||||
})
|
||||
|
||||
test("isNushell handles path and binary name", () => {
|
||||
expect(isNushell("nu")).toBe(true)
|
||||
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
|
||||
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
|
||||
expect(isNushell("/bin/zsh")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
|
||||
const SHELL_ENV_TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
|
||||
export function getUserShell() {
|
||||
return process.env.SHELL || "/bin/sh"
|
||||
}
|
||||
|
||||
export function parseShellEnv(out: Buffer) {
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of out.toString("utf8").split("\0")) {
|
||||
if (!line) continue
|
||||
const ix = line.indexOf("=")
|
||||
if (ix <= 0) continue
|
||||
env[line.slice(0, ix)] = line.slice(ix + 1)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: SHELL_ENV_TIMEOUT,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
return { type: "Loaded", value: env }
|
||||
}
|
||||
|
||||
export function isNushell(shell: string) {
|
||||
const name = basename(shell).toLowerCase()
|
||||
const raw = shell.toLowerCase()
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probeShellEnv(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probeShellEnv(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[cli] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
...(shell || {}),
|
||||
...env,
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@ export type Usage = { input: number; output: number }
|
|||
|
||||
type Line = Record<string, unknown>
|
||||
|
||||
type Flow =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "reason"; text: string }
|
||||
| { type: "tool-start"; id: string; name: string }
|
||||
| { type: "tool-args"; text: string }
|
||||
| { type: "usage"; usage: Usage }
|
||||
|
||||
type Hit = {
|
||||
url: URL
|
||||
body: Record<string, unknown>
|
||||
|
|
@ -119,6 +126,276 @@ function bytes(input: Iterable<unknown>) {
|
|||
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
|
||||
}
|
||||
|
||||
function responseCreated(model: string) {
|
||||
return {
|
||||
type: "response.created",
|
||||
sequence_number: 1,
|
||||
response: {
|
||||
id: "resp_test",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
service_tier: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseCompleted(input: { seq: number; usage?: Usage }) {
|
||||
return {
|
||||
type: "response.completed",
|
||||
sequence_number: input.seq,
|
||||
response: {
|
||||
incomplete_details: null,
|
||||
service_tier: null,
|
||||
usage: {
|
||||
input_tokens: input.usage?.input ?? 0,
|
||||
input_tokens_details: { cached_tokens: null },
|
||||
output_tokens: input.usage?.output ?? 0,
|
||||
output_tokens_details: { reasoning_tokens: null },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessage(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "message", id },
|
||||
}
|
||||
}
|
||||
|
||||
function responseText(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_text.delta",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
delta: text,
|
||||
logprobs: null,
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessageDone(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "message", id },
|
||||
}
|
||||
}
|
||||
|
||||
function responseReason(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id, encrypted_content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonPart(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.reasoning_summary_part.added",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
summary_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonText(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
summary_index: 0,
|
||||
delta: text,
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonDone(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id, encrypted_content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function responseTool(id: string, item: string, name: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: item,
|
||||
call_id: id,
|
||||
name,
|
||||
arguments: "",
|
||||
status: "in_progress",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseToolArgs(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.function_call_arguments.delta",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item_id: id,
|
||||
delta: text,
|
||||
}
|
||||
}
|
||||
|
||||
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: tool.item,
|
||||
call_id: tool.id,
|
||||
name: tool.name,
|
||||
arguments: tool.args,
|
||||
status: "completed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function choices(part: unknown) {
|
||||
if (!part || typeof part !== "object") return
|
||||
if (!("choices" in part) || !Array.isArray(part.choices)) return
|
||||
const choice = part.choices[0]
|
||||
if (!choice || typeof choice !== "object") return
|
||||
return choice
|
||||
}
|
||||
|
||||
function flow(item: Sse) {
|
||||
const out: Flow[] = []
|
||||
for (const part of [...item.head, ...item.tail]) {
|
||||
const choice = choices(part)
|
||||
const delta =
|
||||
choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
|
||||
|
||||
if (delta && "content" in delta && typeof delta.content === "string") {
|
||||
out.push({ type: "text", text: delta.content })
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
||||
out.push({ type: "reason", text: delta.reasoning_content })
|
||||
}
|
||||
|
||||
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
|
||||
for (const tool of delta.tool_calls) {
|
||||
if (!tool || typeof tool !== "object") continue
|
||||
const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
|
||||
if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
|
||||
out.push({ type: "tool-start", id: tool.id, name: fn.name })
|
||||
}
|
||||
if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
|
||||
out.push({ type: "tool-args", text: fn.arguments })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
|
||||
const raw = part.usage as Record<string, unknown>
|
||||
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
|
||||
out.push({
|
||||
type: "usage",
|
||||
usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function responses(item: Sse, model: string) {
|
||||
let seq = 1
|
||||
let msg: string | undefined
|
||||
let reason: string | undefined
|
||||
let hasMsg = false
|
||||
let hasReason = false
|
||||
let call:
|
||||
| {
|
||||
id: string
|
||||
item: string
|
||||
name: string
|
||||
args: string
|
||||
}
|
||||
| undefined
|
||||
let usage: Usage | undefined
|
||||
const lines: unknown[] = [responseCreated(model)]
|
||||
|
||||
for (const part of flow(item)) {
|
||||
if (part.type === "text") {
|
||||
msg ??= "msg_1"
|
||||
if (!hasMsg) {
|
||||
hasMsg = true
|
||||
seq += 1
|
||||
lines.push(responseMessage(msg, seq))
|
||||
}
|
||||
seq += 1
|
||||
lines.push(responseText(msg, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reason") {
|
||||
reason ||= "rs_1"
|
||||
if (!hasReason) {
|
||||
hasReason = true
|
||||
seq += 1
|
||||
lines.push(responseReason(reason, seq))
|
||||
seq += 1
|
||||
lines.push(responseReasonPart(reason, seq))
|
||||
}
|
||||
seq += 1
|
||||
lines.push(responseReasonText(reason, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-start") {
|
||||
call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
|
||||
seq += 1
|
||||
lines.push(responseTool(call.id, call.item, call.name, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-args") {
|
||||
if (!call) continue
|
||||
call.args += part.text
|
||||
seq += 1
|
||||
lines.push(responseToolArgs(call.item, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
usage = part.usage
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
seq += 1
|
||||
lines.push(responseMessageDone(msg, seq))
|
||||
}
|
||||
if (reason) {
|
||||
seq += 1
|
||||
lines.push(responseReasonDone(reason, seq))
|
||||
}
|
||||
if (call && !item.hang && !item.error) {
|
||||
seq += 1
|
||||
lines.push(responseToolDone(call, seq))
|
||||
}
|
||||
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
|
||||
return { ...item, head: lines, tail: [] } satisfies Sse
|
||||
}
|
||||
|
||||
function modelFrom(body: unknown) {
|
||||
if (!body || typeof body !== "object") return "test-model"
|
||||
if (!("model" in body) || typeof body.model !== "string") return "test-model"
|
||||
return body.model
|
||||
}
|
||||
|
||||
function send(item: Sse) {
|
||||
const head = bytes(item.head)
|
||||
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
|
||||
|
|
@ -293,6 +570,13 @@ function item(input: Item | Reply) {
|
|||
return input instanceof Reply ? input.item() : input
|
||||
}
|
||||
|
||||
function hit(url: string, body: unknown) {
|
||||
return {
|
||||
url: new URL(url, "http://localhost"),
|
||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
||||
} satisfies Hit
|
||||
}
|
||||
|
||||
namespace TestLLMServer {
|
||||
export interface Service {
|
||||
readonly url: string
|
||||
|
|
@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
|||
return first
|
||||
}
|
||||
|
||||
yield* router.add(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
Effect.gen(function* () {
|
||||
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const next = pull()
|
||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||
hits = [
|
||||
...hits,
|
||||
{
|
||||
url: new URL(req.originalUrl, "http://localhost"),
|
||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
||||
},
|
||||
]
|
||||
hits = [...hits, hit(req.originalUrl, body)]
|
||||
yield* notify()
|
||||
if (next.type === "sse" && next.reset) {
|
||||
if (next.type !== "sse") return fail(next)
|
||||
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
||||
if (next.reset) {
|
||||
yield* reset(next)
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
if (next.type === "sse") return send(next)
|
||||
return fail(next)
|
||||
}),
|
||||
)
|
||||
return send(next)
|
||||
})
|
||||
|
||||
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
|
||||
yield* router.add("POST", "/v1/responses", handle("responses"))
|
||||
|
||||
yield* server.serve(router.asHttpEffect())
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ export function DockPrompt(props: {
|
|||
children: JSX.Element
|
||||
footer: JSX.Element
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
}) {
|
||||
const slot = (name: string) => `${props.kind}-${name}`
|
||||
|
||||
return (
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
||||
<DockShell data-slot={slot("body")}>
|
||||
<div data-slot={slot("header")}>{props.header}</div>
|
||||
<div data-slot={slot("content")}>{props.children}</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
|
|||
import { useI18n } from "../context/i18n"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
|
|
@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
|
|||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
|
|
@ -85,6 +86,7 @@ type CommentProps = {
|
|||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
|
|
@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
|
|
@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
mention={view().mention}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
|
|
@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
|
|
@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
|
|
|
|||
|
|
@ -178,6 +178,58 @@ export const lineCommentStyles = `
|
|||
box-shadow: var(--shadow-xs-border-select);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
|
@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "
|
|||
autofocus?: boolean
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
mention?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
|
|
@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
|||
"autofocus",
|
||||
"cancelLabel",
|
||||
"submitLabel",
|
||||
"mention",
|
||||
])
|
||||
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [text, setText] = createSignal(split.value)
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
function selectMention(item: { path: string } | undefined) {
|
||||
if (!item) return
|
||||
|
||||
const textarea = refs.textarea
|
||||
const query = currentMention()
|
||||
if (!textarea || !query) return
|
||||
|
||||
const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}`
|
||||
const cursor = query.start + item.path.length + 2
|
||||
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
closeMention()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const mention = useFilteredList<{ path: string }>({
|
||||
items: async (query) => {
|
||||
if (!split.mention) return []
|
||||
if (!query.trim()) return []
|
||||
const paths = await split.mention.items(query)
|
||||
return paths.map((path) => ({ path }))
|
||||
},
|
||||
key: (item) => item.path,
|
||||
filterKeys: ["path"],
|
||||
onSelect: selectMention,
|
||||
})
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
||||
|
|
@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
|||
setText(split.value)
|
||||
})
|
||||
|
||||
const closeMention = () => {
|
||||
setOpen(false)
|
||||
mention.clear()
|
||||
}
|
||||
|
||||
const currentMention = () => {
|
||||
const textarea = refs.textarea
|
||||
if (!textarea) return
|
||||
if (!split.mention) return
|
||||
if (textarea.selectionStart !== textarea.selectionEnd) return
|
||||
|
||||
const end = textarea.selectionStart
|
||||
const match = textarea.value.slice(0, end).match(/@(\S*)$/)
|
||||
if (!match) return
|
||||
|
||||
return {
|
||||
query: match[1] ?? "",
|
||||
start: end - match[0].length,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
const syncMention = () => {
|
||||
const item = currentMention()
|
||||
if (!item) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
mention.onInput(item.query)
|
||||
}
|
||||
|
||||
const selectActiveMention = () => {
|
||||
const items = mention.flat()
|
||||
if (items.length === 0) return
|
||||
const active = mention.active()
|
||||
selectMention(items.find((item) => item.path === active) ?? items[0])
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const value = text().trim()
|
||||
if (!value) return
|
||||
|
|
@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
|||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
syncMention()
|
||||
}}
|
||||
on:click={() => syncMention()}
|
||||
on:select={() => syncMention()}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
event.stopPropagation()
|
||||
if (open()) {
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
if (mention.flat().length === 0) return
|
||||
event.preventDefault()
|
||||
selectActiveMention()
|
||||
return
|
||||
}
|
||||
|
||||
const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
|
||||
const ctrlNav =
|
||||
event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
|
||||
if ((nav || ctrlNav) && mention.flat().length > 0) {
|
||||
mention.onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
|
|
@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
|||
submit()
|
||||
}}
|
||||
/>
|
||||
<Show when={open() && mention.flat().length > 0}>
|
||||
<div data-slot="line-comment-mention-list">
|
||||
<For each={mention.flat().slice(0, 10)}>
|
||||
{(item) => {
|
||||
const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
|
||||
const name = item.path.endsWith("/") ? "" : getFilename(item.path)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-mention-item"
|
||||
data-active={mention.active() === item.path ? "" : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => mention.setActive(item.path)}
|
||||
onClick={() => selectMention(item)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div data-slot="line-comment-mention-path">
|
||||
<span data-slot="line-comment-mention-dir">{directory}</span>
|
||||
<Show when={name}>
|
||||
<span data-slot="line-comment-mention-file">{name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="line-comment-actions">
|
||||
<div data-slot="line-comment-editor-label">
|
||||
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
|
|||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
|
@ -23,8 +22,10 @@ import { Dynamic } from "solid-js/web"
|
|||
import { mediaKindFromPath } from "../pierre/media"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
||||
import { createLineCommentController } from "./line-comment-annotations"
|
||||
import type { LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ export interface SessionReviewProps {
|
|||
split?: boolean
|
||||
diffStyle?: SessionReviewDiffStyle
|
||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||
onDiffRendered?: () => void
|
||||
onDiffRendered?: VoidFunction
|
||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||
|
|
@ -88,6 +89,7 @@ export interface SessionReviewProps {
|
|||
diffs: ReviewDiff[]
|
||||
onViewFile?: (file: string) => void
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
lineCommentMention?: LineCommentEditorProps["mention"]
|
||||
}
|
||||
|
||||
function ReviewCommentMenu(props: {
|
||||
|
|
@ -135,11 +137,14 @@ type SessionReviewSelection = {
|
|||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
|
|
@ -152,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
const open = () => props.open ?? store.open
|
||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||
const grouped = createMemo(() => {
|
||||
const next = new Map<string, SessionReviewComment[]>()
|
||||
for (const comment of props.comments ?? []) {
|
||||
const list = next.get(comment.file)
|
||||
if (list) {
|
||||
list.push(comment)
|
||||
continue
|
||||
}
|
||||
next.set(comment.file, [comment])
|
||||
}
|
||||
return next
|
||||
})
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const handleChange = (open: string[]) => {
|
||||
props.onOpenChange?.(open)
|
||||
if (props.open !== undefined) return
|
||||
setStore("open", open)
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
|
||||
fn(data, event)
|
||||
return
|
||||
}
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
|
|
@ -272,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={props.onScroll as any}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
||||
}}
|
||||
|
|
@ -289,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
const item = createMemo(() => diffs().get(file)!)
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||
|
|
@ -327,6 +405,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
comments,
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
draftKey: () => file,
|
||||
mention: props.lineCommentMention,
|
||||
state: {
|
||||
opened: () => {
|
||||
const current = opened()
|
||||
|
|
@ -378,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
|
|
@ -462,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
|
|
|
|||
Loading…
Reference in New Issue