Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Allan 484a0d5a87 fix(app): remove global project special case and disable session sync 2026-05-18 16:20:11 +08:00
10 changed files with 81 additions and 258 deletions
@@ -77,7 +77,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
if (props.project.id) {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
@@ -85,16 +85,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
@@ -469,9 +469,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: selectedForeground(theme, theme.warning),
foreground: theme.background,
background: theme.warning,
bold: true,
},
@@ -30,7 +30,6 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -199,28 +198,26 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -521,15 +518,14 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const overflow = createMemo(() => lines().length > maxLines)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
})
return (
<Show
@@ -543,11 +539,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -706,17 +702,15 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
return (
<Switch>
@@ -725,12 +719,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -84,7 +84,6 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1697,12 +1696,12 @@ function GenericTool(props: ToolProps<any>) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const overflow = createMemo(() => lines().length > maxLines)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
})
return (
@@ -1717,11 +1716,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1872,16 +1871,14 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
const workdirDisplay = createMemo(() => {
@@ -1905,14 +1902,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1,13 +0,0 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
return { output: Array.from(preview).slice(0, Math.max(0, maxChars - 1)).join("") + "…", overflow: true }
}
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
}
+33 -63
View File
@@ -85,10 +85,6 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
const feed = (list: string[]) => list.join("\0") + "\0"
const feedSpec = (list: string[]) => feed(list.map((item) => `:(top,literal)${item}`))
const scope = path.relative(state.worktree, state.directory).replaceAll("\\", "/")
const spec = scope ? `:(top,literal)${scope}` : "."
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
@@ -126,7 +122,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
"-z",
],
{
cwd: state.worktree,
cwd: state.directory,
stdin: feed(files),
},
)
@@ -142,8 +138,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
],
{
cwd: state.worktree,
stdin: feedSpec(files),
cwd: state.directory,
stdin: feed(files),
},
)
})
@@ -153,8 +149,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
const result = yield* git(
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
{
cwd: state.worktree,
stdin: feedSpec(files),
cwd: state.directory,
stdin: feed(files),
},
)
if (result.code === 0) return
@@ -201,11 +197,11 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
yield* sync()
const [diff, other] = yield* Effect.all(
[
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", spec])], {
cwd: state.worktree,
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
cwd: state.directory,
}),
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", spec])], {
cwd: state.worktree,
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
cwd: state.directory,
}),
],
{ concurrency: 2 },
@@ -243,7 +239,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
(yield* Effect.all(
allow.map((item) =>
fs
.stat(path.join(state.worktree, item))
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.map((stat) => {
@@ -310,9 +306,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
Effect.gen(function* () {
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", spec])],
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
{
cwd: state.worktree,
cwd: state.directory,
},
)
if (result.code !== 0) {
@@ -342,47 +338,24 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
return yield* locked(
Effect.gen(function* () {
log.info("restore", { commit: snapshot })
const listed = yield* git([...quote, ...args(["ls-tree", "-r", "-z", "--name-only", snapshot, "--", spec])], {
cwd: state.worktree,
})
if (listed.code !== 0) {
log.error("failed to list snapshot files", {
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
cwd: state.worktree,
})
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: listed.code,
stderr: listed.stderr,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
const files = listed.text.split("\0").filter(Boolean)
if (!files.length) return
const index = path.join(state.gitdir, "restore.index")
yield* remove(index)
yield* Effect.gen(function* () {
const result = yield* git([...core, ...args(["read-tree", `--index-output=${index}`, snapshot])], {
cwd: state.worktree,
})
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-f", "--stdin", "-z"])], {
cwd: state.worktree,
env: { GIT_INDEX_FILE: index },
stdin: feed(files),
})
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
}).pipe(Effect.ensuring(remove(index)))
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
}),
)
})
@@ -506,12 +479,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
return yield* locked(
Effect.gen(function* () {
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", spec])],
{
cwd: state.worktree,
},
)
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
@@ -667,8 +637,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", spec])],
{ cwd: state.worktree },
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: state.directory },
)
for (const line of statuses.text.trim().split("\n")) {
@@ -679,9 +649,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
}
const numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", spec])],
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: state.worktree,
cwd: state.directory,
},
)
@@ -476,99 +476,6 @@ it.instance(
{ git: true },
)
it.live(
"subdirectory instances stage snapshot files relative to the worktree root",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src`
yield* mkdirp(subdir)
yield* write(`${subdir}/tracked.txt`, "tracked content")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "add subdir"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/date.txt`, "subdirectory content")
const patch = yield* snapshot.patch(before!)
expect(patch.files).toContain(fwd(subdir, "date.txt"))
}).pipe(provideInstance(subdir))
}),
)
it.live(
"subdirectory instances keep gitignored snapshot files out of patches",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src`
yield* mkdirp(subdir)
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
yield* write(`${subdir}/later-ignored.txt`, "initial content")
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/later-ignored.txt`, "modified content")
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
yield* write(`${subdir}/still-tracked.txt`, "new tracked file")
const patch = yield* snapshot.patch(before!)
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
expect(patch.files).toContain(fwd(subdir, "still-tracked.txt"))
}).pipe(provideInstance(subdir))
}),
)
it.live(
"subdirectory restore does not overwrite files outside the subdirectory",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src`
yield* write(`${dir}/root.txt`, "original root")
yield* write(`${subdir}/file.txt`, "original src")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
yield* write(`${dir}/root.txt`, "root snapshot")
expect(yield* snapshot.track()).toBeTruthy()
}).pipe(provideInstance(dir))
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
yield* write(`${subdir}/file.txt`, "src snapshot")
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${dir}/root.txt`, "root current")
yield* write(`${subdir}/file.txt`, "src current")
yield* snapshot.restore(before!)
expect(yield* readText(`${dir}/root.txt`)).toBe("root current")
expect(yield* readText(`${subdir}/file.txt`)).toBe("src snapshot")
}).pipe(provideInstance(subdir))
}),
)
it.live(
"subdirectory scope is treated as a literal git pathspec",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src*`
const sibling = `${dir}/srca`
yield* write(`${subdir}/file.txt`, "literal original")
yield* write(`${sibling}/file.txt`, "sibling original")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/file.txt`, "literal modified")
yield* write(`${sibling}/file.txt`, "sibling modified")
const patch = yield* snapshot.patch(before!)
expect(patch.files).toContain(fwd(subdir, "file.txt"))
expect(patch.files).not.toContain(fwd(sibling, "file.txt"))
}).pipe(provideInstance(subdir))
}),
)
it.instance(
"gitignore updated between track calls filters from diff",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
+1 -1
View File
@@ -935,7 +935,7 @@
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
flex-shrink: 0;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
+9 -36
View File
@@ -58,30 +58,6 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(text).then(
() => true,
() => false,
)
}
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined
@@ -1088,10 +1064,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const handleCopy = async () => {
const content = text()
if (!content) return
if (await writeClipboard(content)) {
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
}
await navigator.clipboard.writeText(content)
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
}
const revert = () => {
@@ -1515,10 +1490,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const handleCopy = async () => {
const content = text()
if (!content) return
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
@@ -1860,10 +1834,9 @@ ToolRegistry.register({
const handleCopy = async () => {
const content = text()
if (!content) return
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (