mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-13 10:14:26 +00:00
Compare commits
162 Commits
node-pty
...
snapshot-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
039263789e | ||
|
|
778f83f1da | ||
|
|
b50ccd0304 | ||
|
|
d6d7c4dca0 | ||
|
|
7c399dabe1 | ||
|
|
d0d02aa1f5 | ||
|
|
61b81667a7 | ||
|
|
3dd9d26f39 | ||
|
|
231906a834 | ||
|
|
2a993fd24f | ||
|
|
403788b84a | ||
|
|
3aea7849fe | ||
|
|
1bb7f82d03 | ||
|
|
4a1284c271 | ||
|
|
fe348aa711 | ||
|
|
fb015e2026 | ||
|
|
e853d43878 | ||
|
|
08253e3b49 | ||
|
|
4329b9a05f | ||
|
|
f2a100d4f2 | ||
|
|
f5f2f1f1af | ||
|
|
4d9badcf06 | ||
|
|
9d37fa797f | ||
|
|
200cb87a20 | ||
|
|
ab4bc07922 | ||
|
|
4b35fba5e8 | ||
|
|
c1b3de66b5 | ||
|
|
49db3d2025 | ||
|
|
2f2d52d859 | ||
|
|
269e37c137 | ||
|
|
ddb66c29fa | ||
|
|
de14df1a59 | ||
|
|
c641ff4eea | ||
|
|
eecc79a43a | ||
|
|
7d9987396f | ||
|
|
624f107494 | ||
|
|
929251f0eb | ||
|
|
51b6f098a8 | ||
|
|
cced1657fd | ||
|
|
95fa647ccb | ||
|
|
f1e6c0fd7a | ||
|
|
a6004ee56c | ||
|
|
fba6930f93 | ||
|
|
50582f4c63 | ||
|
|
9c7cfbcc6f | ||
|
|
818bed81ee | ||
|
|
2d3056dfd8 | ||
|
|
f1dc4301cd | ||
|
|
3beec0471d | ||
|
|
d594d7b971 | ||
|
|
7cc9bcfd63 | ||
|
|
40cc1c6236 | ||
|
|
e2013dc71f | ||
|
|
be13d67539 | ||
|
|
441bbc68fb | ||
|
|
ec44420d71 | ||
|
|
961527b843 | ||
|
|
adc74f5777 | ||
|
|
2325fabc4d | ||
|
|
35273471f4 | ||
|
|
f2f6eae32d | ||
|
|
e2eab65504 | ||
|
|
479ce37ce8 | ||
|
|
99c2de39b2 | ||
|
|
692177a74f | ||
|
|
598fbbb7ca | ||
|
|
f28c85b5ec | ||
|
|
24772320ad | ||
|
|
3b44844849 | ||
|
|
df8464f89c | ||
|
|
3ea387f364 | ||
|
|
9d3c42c8c4 | ||
|
|
f2cad046e6 | ||
|
|
d722026a8d | ||
|
|
42a5af6c8f | ||
|
|
f0542fae7a | ||
|
|
02c75821a8 | ||
|
|
3ba9ab2c0a | ||
|
|
184732fc20 | ||
|
|
b66222baf7 | ||
|
|
dce7eceb28 | ||
|
|
0e077f7483 | ||
|
|
776e7a9c15 | ||
|
|
c455d41876 | ||
|
|
a776a3ee12 | ||
|
|
64fb9233bf | ||
|
|
3533f33ecb | ||
|
|
1cb7df7159 | ||
|
|
a4f8d66a9b | ||
|
|
12efbbfa4c | ||
|
|
13402529ce | ||
|
|
fc678ef36c | ||
|
|
03cd891ea9 | ||
|
|
6314d741e7 | ||
|
|
c45467964c | ||
|
|
2eeba53b07 | ||
|
|
d4107d51f1 | ||
|
|
d8fbe0af01 | ||
|
|
b76ead3fe8 | ||
|
|
51835ecf90 | ||
|
|
328c6de80d | ||
|
|
c9c0318e0e | ||
|
|
d481f64bde | ||
|
|
54e7baa6cf | ||
|
|
1d7fcd40b4 | ||
|
|
db7bafe917 | ||
|
|
b1ef501207 | ||
|
|
9fb12a906e | ||
|
|
fafbc29316 | ||
|
|
7b0def4b81 | ||
|
|
1d9c83b576 | ||
|
|
2c825c3223 | ||
|
|
2a4dedc210 | ||
|
|
b0bca6342e | ||
|
|
547eb7676d | ||
|
|
83f083ee0d | ||
|
|
090f636354 | ||
|
|
d26c6f80e1 | ||
|
|
16a6d6feba | ||
|
|
f1c3a44190 | ||
|
|
34fa5de9c5 | ||
|
|
cb67465675 | ||
|
|
4e73473119 | ||
|
|
cc18fa599c | ||
|
|
aa81c1c4cb | ||
|
|
8569fc1f0e | ||
|
|
78de287bcc | ||
|
|
bbc7052c7a | ||
|
|
502d6db6d0 | ||
|
|
0b0ad5de99 | ||
|
|
9e6c4a01aa | ||
|
|
4a81df190c | ||
|
|
75cae81f75 | ||
|
|
ed3bb3ea8f | ||
|
|
fac23a1afc | ||
|
|
f89696509e | ||
|
|
604ab1bde1 | ||
|
|
fbd9b7cf4f | ||
|
|
58f45ae22b | ||
|
|
440405dbdd | ||
|
|
a1cda29012 | ||
|
|
f96e2d4222 | ||
|
|
387ab78bf6 | ||
|
|
dbc00aa8e0 | ||
|
|
c37f7b9d99 | ||
|
|
cf7ca9b2f7 | ||
|
|
981c7b9e37 | ||
|
|
2aae0d3493 | ||
|
|
bcc0d19867 | ||
|
|
9c585bb58b | ||
|
|
0f6bc8ae71 | ||
|
|
7291e28273 | ||
|
|
db57fe6193 | ||
|
|
802416639b | ||
|
|
7ec398d855 | ||
|
|
4ab35d2c5c | ||
|
|
b4ae030fc2 | ||
|
|
0843964eb3 | ||
|
|
a1b06d63c9 | ||
|
|
1b6820bab5 | ||
|
|
89bf199c07 | ||
|
|
5acfdd1c5d |
21
.github/actions/setup-bun/action.yml
vendored
21
.github/actions/setup-bun/action.yml
vendored
@@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
@@ -31,6 +23,19 @@ runs:
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
shell: bash
|
||||
|
||||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
@@ -115,6 +115,9 @@ jobs:
|
||||
target: x86_64-apple-darwin
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: windows-2025
|
||||
target: aarch64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -149,6 +152,10 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
@@ -254,6 +261,10 @@ jobs:
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform_flag: --mac --arm64
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: "windows-2025"
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform_flag: --win --arm64
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform_flag: --win
|
||||
|
||||
8
.opencode/.gitignore
vendored
8
.opencode/.gitignore
vendored
@@ -1,6 +1,4 @@
|
||||
node_modules
|
||||
plans
|
||||
package.json
|
||||
plans/
|
||||
bun.lock
|
||||
.gitignore
|
||||
package-lock.json
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
@@ -48,11 +48,11 @@
|
||||
"light": "nord10"
|
||||
},
|
||||
"text": {
|
||||
"dark": "nord4",
|
||||
"dark": "nord6",
|
||||
"light": "nord0"
|
||||
},
|
||||
"textMuted": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord1"
|
||||
},
|
||||
"background": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"backgroundElement": {
|
||||
"dark": "nord1",
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
},
|
||||
"border": {
|
||||
@@ -88,11 +88,11 @@
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffContext": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHunkHeader": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHighlightAdded": {
|
||||
@@ -104,12 +104,12 @@
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffAddedBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#36413C",
|
||||
"light": "#E6EBE7"
|
||||
},
|
||||
"diffRemovedBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#43393D",
|
||||
"light": "#ECE6E8"
|
||||
},
|
||||
"diffContextBg": {
|
||||
"dark": "nord1",
|
||||
@@ -120,12 +120,12 @@
|
||||
"light": "nord4"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#303A35",
|
||||
"light": "#DDE4DF"
|
||||
},
|
||||
"diffRemovedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#3C3336",
|
||||
"light": "#E4DDE0"
|
||||
},
|
||||
"markdownText": {
|
||||
"dark": "nord4",
|
||||
@@ -148,7 +148,7 @@
|
||||
"light": "nord14"
|
||||
},
|
||||
"markdownBlockQuote": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownEmph": {
|
||||
@@ -160,7 +160,7 @@
|
||||
"light": "nord13"
|
||||
},
|
||||
"markdownHorizontalRule": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownListItem": {
|
||||
@@ -184,7 +184,7 @@
|
||||
"light": "nord0"
|
||||
},
|
||||
"syntaxComment": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"syntaxKeyword": {
|
||||
912
.opencode/plugins/tui-smoke.tsx
Normal file
912
.opencode/plugins/tui-smoke.tsx
Normal file
@@ -0,0 +1,912 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor } from "@opentui/solid"
|
||||
import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core"
|
||||
import { ThreeRenderable, THREE } from "@opentui/core/3d"
|
||||
import type { TuiApi, TuiKeybindSet, TuiPluginInit, TuiPluginInput } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tabs = ["overview", "counter", "help"]
|
||||
const bind = {
|
||||
modal: "ctrl+shift+m",
|
||||
screen: "ctrl+shift+o",
|
||||
home: "escape,ctrl+h",
|
||||
left: "left,h",
|
||||
right: "right,l",
|
||||
up: "up,k",
|
||||
down: "down,j",
|
||||
alert: "a",
|
||||
confirm: "c",
|
||||
prompt: "p",
|
||||
select: "s",
|
||||
modal_accept: "enter,return",
|
||||
modal_close: "escape",
|
||||
dialog_close: "escape",
|
||||
local: "x",
|
||||
local_push: "enter,return",
|
||||
local_close: "q,backspace",
|
||||
host: "z",
|
||||
}
|
||||
|
||||
const pick = (value: unknown, fallback: string) => {
|
||||
if (typeof value !== "string") return fallback
|
||||
if (!value.trim()) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const num = (value: unknown, fallback: number) => {
|
||||
if (typeof value !== "number") return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const rec = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
type Cfg = {
|
||||
label: string
|
||||
route: string
|
||||
vignette: number
|
||||
keybinds: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
type Route = {
|
||||
modal: string
|
||||
screen: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
tab: number
|
||||
count: number
|
||||
source: string
|
||||
note: string
|
||||
selected: string
|
||||
local: number
|
||||
}
|
||||
|
||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
return {
|
||||
label: pick(options?.label, "smoke"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keybinds: rec(options?.keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
const names = (input: Cfg) => {
|
||||
return {
|
||||
modal: `${input.route}.modal`,
|
||||
screen: `${input.route}.screen`,
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = TuiKeybindSet
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
text: "#f0f0f0",
|
||||
muted: "#a5a5a5",
|
||||
accent: "#5f87ff",
|
||||
}
|
||||
|
||||
type Color = RGBA | string
|
||||
|
||||
const tone = (api: TuiApi) => {
|
||||
const map = api.theme.current as Record<string, unknown>
|
||||
const get = (name: string, fallback: string): Color => {
|
||||
const value = map[name]
|
||||
if (typeof value === "string") return value
|
||||
if (value && typeof value === "object") return value as RGBA
|
||||
return fallback
|
||||
}
|
||||
return {
|
||||
panel: get("backgroundPanel", ui.panel),
|
||||
border: get("border", ui.border),
|
||||
text: get("text", ui.text),
|
||||
muted: get("textMuted", ui.muted),
|
||||
accent: get("primary", ui.accent),
|
||||
selected: get("selectedListItemText", ui.text),
|
||||
}
|
||||
}
|
||||
|
||||
type Skin = {
|
||||
panel: Color
|
||||
border: Color
|
||||
text: Color
|
||||
muted: Color
|
||||
accent: Color
|
||||
selected: Color
|
||||
}
|
||||
type CubeOpts = ConstructorParameters<typeof ThreeRenderable>[1] & {
|
||||
tint?: Color
|
||||
spec?: Color
|
||||
ambient?: Color
|
||||
key_light?: Color
|
||||
fill_light?: Color
|
||||
}
|
||||
|
||||
const rgb = (value: unknown, fallback: string) => {
|
||||
if (typeof value === "string") return new THREE.Color(value)
|
||||
if (value && typeof value === "object") {
|
||||
const item = value as { r?: unknown; g?: unknown; b?: unknown }
|
||||
if (typeof item.r === "number" && typeof item.g === "number" && typeof item.b === "number") {
|
||||
return new THREE.Color(item.r, item.g, item.b)
|
||||
}
|
||||
}
|
||||
return new THREE.Color(fallback)
|
||||
}
|
||||
|
||||
class Cube extends ThreeRenderable {
|
||||
private cube: THREE.Mesh
|
||||
private mat: THREE.MeshPhongMaterial
|
||||
private amb: THREE.AmbientLight
|
||||
private key: THREE.DirectionalLight
|
||||
private fill: THREE.DirectionalLight
|
||||
|
||||
constructor(ctx: RenderContext, opts: CubeOpts) {
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100)
|
||||
camera.position.set(0, 0, 2.55)
|
||||
|
||||
const amb = new THREE.AmbientLight(rgb(opts.ambient, "#666666"), 1.0)
|
||||
scene.add(amb)
|
||||
|
||||
const key = new THREE.DirectionalLight(rgb(opts.key_light, "#fff2e6"), 1.2)
|
||||
key.position.set(2.5, 2.0, 3.0)
|
||||
scene.add(key)
|
||||
|
||||
const fill = new THREE.DirectionalLight(rgb(opts.fill_light, "#80b3ff"), 0.6)
|
||||
fill.position.set(-2.0, -1.5, 2.5)
|
||||
scene.add(fill)
|
||||
|
||||
const geo = new THREE.BoxGeometry(1.0, 1.0, 1.0)
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color: rgb(opts.tint, "#40ccff"),
|
||||
shininess: 80,
|
||||
specular: rgb(opts.spec, "#e6e6ff"),
|
||||
})
|
||||
const cube = new THREE.Mesh(geo, mat)
|
||||
cube.scale.setScalar(1.12)
|
||||
scene.add(cube)
|
||||
|
||||
super(ctx, {
|
||||
...opts,
|
||||
scene,
|
||||
camera,
|
||||
renderer: {
|
||||
focalLength: 8,
|
||||
alpha: true,
|
||||
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
|
||||
},
|
||||
})
|
||||
|
||||
this.cube = cube
|
||||
this.mat = mat
|
||||
this.amb = amb
|
||||
this.key = key
|
||||
this.fill = fill
|
||||
}
|
||||
|
||||
set tint(value: Color | undefined) {
|
||||
this.mat.color.copy(rgb(value, "#40ccff"))
|
||||
}
|
||||
|
||||
set spec(value: Color | undefined) {
|
||||
this.mat.specular.copy(rgb(value, "#e6e6ff"))
|
||||
}
|
||||
|
||||
set ambient(value: Color | undefined) {
|
||||
this.amb.color.copy(rgb(value, "#666666"))
|
||||
}
|
||||
|
||||
set key_light(value: Color | undefined) {
|
||||
this.key.color.copy(rgb(value, "#fff2e6"))
|
||||
}
|
||||
|
||||
set fill_light(value: Color | undefined) {
|
||||
this.fill.color.copy(rgb(value, "#80b3ff"))
|
||||
}
|
||||
|
||||
protected override renderSelf(buf: OptimizedBuffer, dt: number): void {
|
||||
const delta = dt / 1000
|
||||
this.cube.rotation.x += delta * 0.6
|
||||
this.cube.rotation.y += delta * 0.4
|
||||
this.cube.rotation.z += delta * 0.2
|
||||
super.renderSelf(buf, dt)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
smoke_cube: RenderableConstructor
|
||||
}
|
||||
}
|
||||
|
||||
extend({ smoke_cube: Cube as unknown as RenderableConstructor })
|
||||
|
||||
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
|
||||
return (
|
||||
<box
|
||||
onMouseUp={() => {
|
||||
props.run()
|
||||
}}
|
||||
backgroundColor={props.on ? props.skin.accent : props.skin.border}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const parse = (params: Record<string, unknown> | undefined) => {
|
||||
const tab = typeof params?.tab === "number" ? params.tab : 0
|
||||
const count = typeof params?.count === "number" ? params.count : 0
|
||||
const source = typeof params?.source === "string" ? params.source : "unknown"
|
||||
const note = typeof params?.note === "string" ? params.note : ""
|
||||
const selected = typeof params?.selected === "string" ? params.selected : ""
|
||||
const local = typeof params?.local === "number" ? params.local : 0
|
||||
return {
|
||||
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
||||
count,
|
||||
source,
|
||||
note,
|
||||
selected,
|
||||
local: Math.max(0, local),
|
||||
}
|
||||
}
|
||||
|
||||
const current = (api: TuiApi, route: Route) => {
|
||||
const value = api.route.current
|
||||
const ok = Object.values(route).includes(value.name)
|
||||
if (!ok) return parse(undefined)
|
||||
if (!("params" in value)) return parse(undefined)
|
||||
return parse(value.params)
|
||||
}
|
||||
|
||||
const opts = [
|
||||
{
|
||||
title: "Overview",
|
||||
value: 0,
|
||||
description: "Switch to overview tab",
|
||||
},
|
||||
{
|
||||
title: "Counter",
|
||||
value: 1,
|
||||
description: "Switch to counter tab",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: 2,
|
||||
description: "Switch to help tab",
|
||||
},
|
||||
]
|
||||
|
||||
const host = (api: TuiApi, input: Cfg, skin: Skin) => {
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{input.label} host overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
||||
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
||||
</box>
|
||||
</box>
|
||||
))
|
||||
}
|
||||
|
||||
const warn = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogAlert = api.ui.DialogAlert
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogAlert
|
||||
title="Smoke alert"
|
||||
message="Testing built-in alert dialog"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const check = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogConfirm = api.ui.DialogConfirm
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogConfirm
|
||||
title="Smoke confirm"
|
||||
message="Apply +1 to counter?"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
||||
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const entry = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogPrompt = api.ui.DialogPrompt
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Smoke prompt"
|
||||
value={value.note}
|
||||
onConfirm={(note) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
||||
}}
|
||||
onCancel={() => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const picker = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogSelect = api.ui.DialogSelect
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Smoke select"
|
||||
options={opts}
|
||||
current={value.tab}
|
||||
onSelect={(item) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, {
|
||||
...value,
|
||||
tab: typeof item.value === "number" ? item.value : value.tab,
|
||||
selected: item.title,
|
||||
source: "select",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const Screen = (props: {
|
||||
api: TuiApi
|
||||
input: Cfg
|
||||
route: Route
|
||||
keys: Keys
|
||||
meta: TuiPluginInit
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const dim = useTerminalDimensions()
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
const set = (local: number, base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
||||
}
|
||||
const push = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
set(next.local + 1, next)
|
||||
}
|
||||
const open = () => {
|
||||
const next = current(props.api, props.route)
|
||||
if (next.local > 0) return
|
||||
set(1, next)
|
||||
}
|
||||
const pop = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
const local = Math.max(0, next.local - 1)
|
||||
set(local, next)
|
||||
}
|
||||
const show = () => {
|
||||
setTimeout(() => {
|
||||
open()
|
||||
}, 0)
|
||||
}
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.screen) return
|
||||
const next = current(props.api, props.route)
|
||||
if (props.api.ui.dialog.open) {
|
||||
if (props.keys.match("dialog_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.ui.dialog.clear()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (next.local > 0) {
|
||||
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
pop(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local_push", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("home", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("left", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("right", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("up", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("down", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.modal, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
open()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("host", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
host(props.api, props.input, skin)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("alert", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
warn(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("confirm", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
check(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("prompt", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
entry(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("select", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
picker(props.api, props.route, next)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} screen</b>
|
||||
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||
{tabs.map((item, i) => {
|
||||
const on = value.tab === i
|
||||
return (
|
||||
<Btn
|
||||
txt={item}
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
||||
skin={skin}
|
||||
on={on}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexGrow={1}
|
||||
>
|
||||
{value.tab === 0 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Route: {props.route.screen}</text>
|
||||
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
||||
<text fg={skin.muted}>
|
||||
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
||||
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.entry.load_count}
|
||||
</text>
|
||||
<text fg={skin.muted}>plugin source: {props.meta.entry.source}</text>
|
||||
<text fg={skin.muted}>source: {value.source}</text>
|
||||
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
||||
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
||||
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 1 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Counter: {value.count}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 2 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
||||
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
||||
close
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingTop={1}>
|
||||
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
||||
<Btn txt="local overlay" run={show} skin={skin} />
|
||||
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
||||
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
visible={value.local > 0}
|
||||
width={dim().width}
|
||||
height={dim().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dim().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
||||
onMouseUp={() => {
|
||||
pop()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
onMouseUp={(evt) => {
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
width={60}
|
||||
maxWidth={dim().width - 2}
|
||||
backgroundColor={skin.panel}
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} local overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="push" run={push} skin={skin} on />
|
||||
<Btn txt="pop" run={pop} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; params?: Record<string, unknown> }) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.modal) return
|
||||
|
||||
if (props.keys.match("modal_accept", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
||||
<Dialog onClose={() => props.api.route.navigate("home")}>
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} modal</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
||||
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn
|
||||
txt="open screen"
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
||||
skin={skin}
|
||||
on
|
||||
/>
|
||||
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</Dialog>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const slot = (input: Cfg) => ({
|
||||
id: "workspace-smoke",
|
||||
slots: {
|
||||
home_logo(ctx) {
|
||||
const map = ctx.theme.current as Record<string, unknown>
|
||||
const get = (name: string, fallback: string) => {
|
||||
const value = map[name]
|
||||
if (typeof value === "string") return value
|
||||
if (value && typeof value === "object") return value as RGBA
|
||||
return fallback
|
||||
}
|
||||
const art = [
|
||||
" $$\\",
|
||||
" $$ |",
|
||||
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
|
||||
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
|
||||
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
|
||||
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
|
||||
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
|
||||
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
|
||||
]
|
||||
const ink = [
|
||||
get("primary", ui.accent),
|
||||
get("textMuted", ui.muted),
|
||||
get("info", ui.accent),
|
||||
get("text", ui.text),
|
||||
get("success", ui.accent),
|
||||
get("warning", ui.accent),
|
||||
get("secondary", ui.accent),
|
||||
get("error", ui.accent),
|
||||
]
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{art.map((line, i) => (
|
||||
<text fg={ink[i]}>{line}</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
},
|
||||
sidebar_top(ctx, value) {
|
||||
const map = ctx.theme.current as Record<string, unknown>
|
||||
const get = (name: string, fallback: string) => {
|
||||
const item = map[name]
|
||||
if (typeof item === "string") return item
|
||||
if (item && typeof item === "object") return item as RGBA
|
||||
return fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<smoke_cube
|
||||
id={`smoke-cube-${value.session_id.slice(0, 8)}`}
|
||||
width="100%"
|
||||
height={16}
|
||||
tint={get("primary", ui.accent)}
|
||||
spec={get("text", ui.text)}
|
||||
ambient={get("textMuted", ui.muted)}
|
||||
key_light={get("success", ui.accent)}
|
||||
fill_light={get("info", ui.accent)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const reg = (api: TuiApi, input: Cfg, keys: Keys) => {
|
||||
const route = names(input)
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: `${input.label} modal`,
|
||||
value: "plugin.smoke.modal",
|
||||
keybind: keys.get("modal"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.modal, { source: "command" })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} screen`,
|
||||
value: "plugin.smoke.screen",
|
||||
keybind: keys.get("screen"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-screen",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} alert dialog`,
|
||||
value: "plugin.smoke.alert",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-alert",
|
||||
},
|
||||
onSelect: () => {
|
||||
warn(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} confirm dialog`,
|
||||
value: "plugin.smoke.confirm",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-confirm",
|
||||
},
|
||||
onSelect: () => {
|
||||
check(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} prompt dialog`,
|
||||
value: "plugin.smoke.prompt",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-prompt",
|
||||
},
|
||||
onSelect: () => {
|
||||
entry(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} select dialog`,
|
||||
value: "plugin.smoke.select",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-select",
|
||||
},
|
||||
onSelect: () => {
|
||||
picker(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} host overlay`,
|
||||
value: "plugin.smoke.host",
|
||||
keybind: keys.get("host"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-host",
|
||||
},
|
||||
onSelect: () => {
|
||||
host(api, input, tone(api))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} go home`,
|
||||
value: "plugin.smoke.home",
|
||||
category: "Plugin",
|
||||
enabled: api.route.current.name !== "home",
|
||||
onSelect: () => {
|
||||
api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} toast`,
|
||||
value: "plugin.smoke.toast",
|
||||
category: "Plugin",
|
||||
onSelect: () => {
|
||||
api.ui.toast({
|
||||
variant: "info",
|
||||
title: "Smoke",
|
||||
message: "Plugin toast works",
|
||||
duration: 2000,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const tui = async (input: TuiPluginInput, options: Record<string, unknown> | null, meta: TuiPluginInit) => {
|
||||
if (options?.enabled === false) return
|
||||
|
||||
await input.api.theme.install("./smoke-theme.json")
|
||||
input.api.theme.set("smoke-theme")
|
||||
|
||||
const value = cfg(options ?? undefined)
|
||||
const route = names(value)
|
||||
const keys = input.api.keybind.create(bind, value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
input.renderer.addPostProcessFn(fx.apply.bind(fx))
|
||||
|
||||
input.api.route.register([
|
||||
{
|
||||
name: route.screen,
|
||||
render: ({ params }) => (
|
||||
<Screen api={input.api} input={value} route={route} keys={keys} meta={meta} params={params} />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: route.modal,
|
||||
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
])
|
||||
|
||||
reg(input.api, value, keys)
|
||||
input.slots.register(slot(value))
|
||||
}
|
||||
|
||||
export default {
|
||||
tui,
|
||||
}
|
||||
1
.opencode/themes/.gitignore
vendored
Normal file
1
.opencode/themes/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
smoke-theme.json
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-pr-search.txt"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
@@ -23,16 +24,7 @@ interface PR {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: `Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||
|
||||
10
.opencode/tool/github-pr-search.txt
Normal file
10
.opencode/tool/github-pr-search.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-triage.txt"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
@@ -39,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: `Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
|
||||
6
.opencode/tool/github-triage.txt
Normal file
6
.opencode/tool/github-triage.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
19
.opencode/tui.json
Normal file
19
.opencode/tui.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": true,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -103,6 +103,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||
name: "OpenCode Go",
|
||||
})
|
||||
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||
name: "First month 50% off",
|
||||
percentOff: 50,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "once",
|
||||
})
|
||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||
product: zenLiteProduct.id,
|
||||
currency: "usd",
|
||||
@@ -116,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||
properties: {
|
||||
product: zenLiteProduct.id,
|
||||
price: zenLitePrice.id,
|
||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
@@ -42,9 +41,9 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -99,7 +98,6 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
|
||||
@@ -70,6 +70,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
- `openSettings(page)` - Open settings dialog
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||
@@ -167,6 +169,13 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
```
|
||||
|
||||
### Terminal Tests
|
||||
|
||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Choose appropriate folder or create new one
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
@@ -28,6 +30,53 @@ export async function defocus(page: Page) {
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function terminalID(term: Locator) {
|
||||
const id = await term.getAttribute(terminalAttr)
|
||||
if (id) return id
|
||||
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||
}
|
||||
|
||||
async function terminalReady(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return !!state?.connected && (state.settled ?? 0) > 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||
const next = input.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate(
|
||||
(input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||
return state?.rendered.includes(input.token) ?? false
|
||||
},
|
||||
{ id, token: input.token },
|
||||
)
|
||||
}
|
||||
|
||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await expect(term).toBeVisible()
|
||||
await expect(term.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||
const term = input.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
const textarea = term.locator("textarea")
|
||||
await term.click()
|
||||
await expect(textarea).toBeFocused()
|
||||
await page.keyboard.type(input.cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
@@ -91,6 +92,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
},
|
||||
}
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -6,18 +7,29 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await prompt.fill("/terminal")
|
||||
await expect(slash).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||
// which can steal focus from the prompt and prevent fill() from triggering
|
||||
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||
// and the popover appears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill("/terminal").catch(() => false)
|
||||
return slash.isVisible().catch(() => false)
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||
|
||||
217
packages/app/e2e/session/session-review.spec.ts
Normal file
217
packages/app/e2e/session/session-review.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { waitSessionIdle, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const count = 14
|
||||
|
||||
function body(mark: string) {
|
||||
return [
|
||||
`title ${mark}`,
|
||||
`mark ${mark}`,
|
||||
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||
]
|
||||
}
|
||||
|
||||
function files(tag: string) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const id = String(i).padStart(2, "0")
|
||||
return {
|
||||
file: `review-scroll-${id}.txt`,
|
||||
mark: `${tag}-${id}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seed(list: ReturnType<typeof files>) {
|
||||
const out = ["*** Begin Patch"]
|
||||
|
||||
for (const item of list) {
|
||||
out.push(`*** Add File: ${item.file}`)
|
||||
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||
}
|
||||
|
||||
out.push("*** End Patch")
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
function edit(file: string, prev: string, next: string) {
|
||||
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
system: [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Your only valid response is one apply_patch tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||
"Do not call any other tools.",
|
||||
"Do not output plain text.",
|
||||
].join("\n"),
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
})
|
||||
|
||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||
}
|
||||
|
||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||
await expect(btn).toBeVisible()
|
||||
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||
const open = await close
|
||||
.isVisible()
|
||||
.then((value) => value)
|
||||
.catch(() => false)
|
||||
|
||||
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||
if (open) {
|
||||
await close.click()
|
||||
await expect(btn).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click()
|
||||
await expect(close).toBeVisible()
|
||||
}
|
||||
|
||||
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||
await page.waitForFunction(
|
||||
({ file, mark }) => {
|
||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||
if (!(view instanceof HTMLElement)) return false
|
||||
|
||||
const head = Array.from(view.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(head instanceof HTMLElement)) return false
|
||||
|
||||
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||
if (!(host instanceof HTMLElement)) return false
|
||||
const root = host.shadowRoot
|
||||
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||
})
|
||||
},
|
||||
{ file, mark },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
return page.evaluate((file) => {
|
||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||
if (!(view instanceof HTMLElement)) return null
|
||||
|
||||
const row = Array.from(view.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(row instanceof HTMLElement)) return null
|
||||
|
||||
const a = row.getBoundingClientRect()
|
||||
const b = view.getBoundingClientRect()
|
||||
return {
|
||||
top: a.top - b.top,
|
||||
y: view.scrollTop,
|
||||
}
|
||||
}, file)
|
||||
}
|
||||
|
||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-${Date.now()}`
|
||||
const list = files(tag)
|
||||
const hit = list[list.length - 4]!
|
||||
const next = `${tag}-live`
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed(list))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||
return info?.summary?.files ?? 0
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
await expect(view).toBeVisible()
|
||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||
await expect(heads).toHaveCount(list.length, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, hit.file, hit.mark)
|
||||
|
||||
const row = page
|
||||
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||
.first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||
|
||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||
const prev = await spot(page, hit.file)
|
||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||
|
||||
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
const item = diff.find((item) => item.file === hit.file)
|
||||
return typeof item?.after === "string" ? item.after : ""
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toContain(`mark ${next}`)
|
||||
|
||||
await waitMark(page, hit.file, next)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const next = await spot(page, hit.file)
|
||||
if (!next) return Number.POSITIVE_INFINITY
|
||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(32)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await expect(terminals.first()).toBeVisible()
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(terminals).toHaveCount(1)
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { runTerminal, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
@@ -17,16 +18,7 @@ async function open(page: Page) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const visible = await terminal.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
}
|
||||
|
||||
async function run(page: Page, cmd: string) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).toBeVisible()
|
||||
await terminal.click()
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
}
|
||||
|
||||
async function store(page: Page, key: string) {
|
||||
@@ -56,15 +48,16 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await run(page, `echo ${one}`)
|
||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
await run(page, `echo ${two}`)
|
||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -76,7 +69,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
@@ -93,7 +86,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
}
|
||||
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
})
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import "@/index.css"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { type Duration, Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createResource,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
For,
|
||||
type JSX,
|
||||
lazy,
|
||||
onCleanup,
|
||||
type ParentProps,
|
||||
Show,
|
||||
Suspense,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -62,6 +77,9 @@ declare global {
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +133,11 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider>
|
||||
<ThemeProvider
|
||||
onThemeApplied={(_, mode) => {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
@@ -132,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const effectMinDuration =
|
||||
(duration: Duration.Input) =>
|
||||
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||
|
||||
function ConnectionGate(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||
|
||||
// performs repeated health check with a grace period for
|
||||
// non-http connections, otherwise fails instantly
|
||||
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||
Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={startupHealthCheck()}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||
const server = useServer()
|
||||
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||
|
||||
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
|
||||
return (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||
<div class="flex flex-col items-center max-w-md text-center">
|
||||
<Splash class="w-12 h-15 mb-4" />
|
||||
<p class="text-14-regular text-text-base">
|
||||
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
|
||||
</p>
|
||||
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
|
||||
</div>
|
||||
<Show when={others().length > 0}>
|
||||
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||
<span class="text-12-regular text-text-base text-center">Other servers</span>
|
||||
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||
<For each={others()}>
|
||||
{(conn) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => props.onServerSelected?.(key)}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
@@ -149,7 +264,7 @@ export function AppInterface(props: {
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerKey>
|
||||
<ConnectionGate>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
@@ -164,7 +279,7 @@ export function AppInterface(props: {
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,28 +382,28 @@ export function DebugBar() {
|
||||
dim={state.fps === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="FRM"
|
||||
label="FRAME"
|
||||
tip="Worst frame time over the last 5 seconds."
|
||||
value={time(state.gap)}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="JNK"
|
||||
label="JANK"
|
||||
tip="Frames over 32ms in the last 5 seconds."
|
||||
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="LNG"
|
||||
label="LONG"
|
||||
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="DLY"
|
||||
label="DELAY"
|
||||
tip="Worst observed input delay in the last 5 seconds."
|
||||
value={time(state.delay)}
|
||||
bad={bad(state.delay, 100)}
|
||||
|
||||
159
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
159
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
|
||||
export type ModelErr = {
|
||||
id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type HeaderErr = {
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export type ModelRow = {
|
||||
row: string
|
||||
id: string
|
||||
name: string
|
||||
err: ModelErr
|
||||
}
|
||||
|
||||
export type HeaderRow = {
|
||||
row: string
|
||||
key: string
|
||||
value: string
|
||||
err: HeaderErr
|
||||
}
|
||||
|
||||
export type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
err: {
|
||||
providerID?: string
|
||||
name?: string
|
||||
baseURL?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
export function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const models = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const idError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: idError, name: nameError }
|
||||
})
|
||||
const modelsValid = models.every((m) => !m.id && !m.name)
|
||||
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headers = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headers.every((h) => !h.key && !h.value)
|
||||
const headerConfig = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const err = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { err, models, headers }
|
||||
|
||||
return {
|
||||
err,
|
||||
models,
|
||||
headers,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options: {
|
||||
baseURL,
|
||||
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
||||
},
|
||||
models: modelConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let row = 0
|
||||
|
||||
const nextRow = () => `row-${row++}`
|
||||
|
||||
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
||||
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
||||
82
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
82
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe("validateCustomProvider", () => {
|
||||
test("builds trimmed config payload", () => {
|
||||
const result = validateCustomProvider({
|
||||
form: {
|
||||
providerID: "custom-provider",
|
||||
name: " Custom Provider ",
|
||||
baseURL: "https://api.example.com ",
|
||||
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
||||
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
||||
headers: [
|
||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||
{ row: "h1", key: "", value: "", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
disabledProviders: [],
|
||||
existingProviderIDs: new Set(),
|
||||
})
|
||||
|
||||
expect(result.result).toEqual({
|
||||
providerID: "custom-provider",
|
||||
name: "Custom Provider",
|
||||
key: undefined,
|
||||
config: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Custom Provider",
|
||||
env: ["CUSTOM_PROVIDER_KEY"],
|
||||
options: {
|
||||
baseURL: "https://api.example.com",
|
||||
headers: {
|
||||
"X-Test": "enabled",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
"model-a": { name: "Model A" },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
||||
const result = validateCustomProvider({
|
||||
form: {
|
||||
providerID: "custom-provider",
|
||||
name: "Provider",
|
||||
baseURL: "https://api.example.com",
|
||||
apiKey: "secret",
|
||||
models: [
|
||||
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
||||
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
||||
],
|
||||
headers: [
|
||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
disabledProviders: ["custom-provider"],
|
||||
existingProviderIDs: new Set(["custom-provider"]),
|
||||
})
|
||||
|
||||
expect(result.result).toBeUndefined()
|
||||
expect(result.err.providerID).toBeUndefined()
|
||||
expect(result.models[1]).toEqual({
|
||||
id: "provider.custom.error.duplicate",
|
||||
name: undefined,
|
||||
})
|
||||
expect(result.headers[1]).toEqual({
|
||||
key: "provider.custom.error.duplicate",
|
||||
value: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||
|
||||
type ModelRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type HeaderRow = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
type FormErrors = {
|
||||
providerID: string | undefined
|
||||
name: string | undefined
|
||||
baseURL: string | undefined
|
||||
models: Array<{ id?: string; name?: string }>
|
||||
headers: Array<{ key?: string; value?: string }>
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headerErrors = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
const headers = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const errors: FormErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { errors }
|
||||
|
||||
const options = {
|
||||
baseURL,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options,
|
||||
models,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
}
|
||||
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
|
||||
name: "",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
models: [{ id: "", name: "" }],
|
||||
headers: [{ key: "", value: "" }],
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = createStore<FormErrors>({
|
||||
providerID: undefined,
|
||||
name: undefined,
|
||||
baseURL: undefined,
|
||||
models: [{}],
|
||||
headers: [{}],
|
||||
err: {},
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||
setErrors("models", (v) => [...v, {}])
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.push(modelRow())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeModel = (index: number) => {
|
||||
if (form.models.length <= 1) return
|
||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const addHeader = () => {
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.push(headerRow())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
if (form.headers.length <= 1) return
|
||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
||||
setForm(key, value)
|
||||
if (key === "apiKey") return
|
||||
setForm("err", key, undefined)
|
||||
}
|
||||
|
||||
const setModel = (index: number, key: "id" | "name", value: string) => {
|
||||
batch(() => {
|
||||
setForm("models", index, key, value)
|
||||
setForm("models", index, "err", key, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
||||
batch(() => {
|
||||
setForm("headers", index, key, value)
|
||||
setForm("headers", index, "err", key, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
})
|
||||
setErrors(output.errors)
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
||||
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
||||
})
|
||||
return output.result
|
||||
}
|
||||
|
||||
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setForm("providerID", v)}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setForm("name", v)}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setForm("baseURL", v)}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm("apiKey", v)}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setForm("models", i(), "id", v)}
|
||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setForm("models", i(), "name", v)}
|
||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -382,16 +272,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
@@ -132,9 +134,14 @@ function createFileEntries(props: {
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const tabState = createSessionTabs({
|
||||
tabs: props.tabs,
|
||||
pathFromTab: props.file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||
})
|
||||
const recent = createMemo(() => {
|
||||
const all = props.tabs().all()
|
||||
const active = props.tabs().active()
|
||||
const all = tabState.openedTabs()
|
||||
const active = tabState.activeFileTab()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("palette.group.files")
|
||||
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||
|
||||
@@ -14,7 +14,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -41,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
@@ -56,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const setDefault = async (url: string | null) => {
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServerUrl?.(url)
|
||||
defaultUrlActions.mutate(url)
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultUrl, canDefault, setDefault }
|
||||
return { defaultKey, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview(fetcher: typeof fetch) {
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
@@ -92,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http, fetcher)
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
@@ -170,15 +176,15 @@ export function DialogSelectServer() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
@@ -201,7 +207,7 @@ export function DialogSelectServer() {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
@@ -264,7 +270,7 @@ export function DialogSelectServer() {
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
items().map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -362,9 +368,9 @@ export function DialogSelectServer() {
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.username) conn.http.username = store.addServer.username
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("addServer", { adding: false })
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
@@ -404,7 +410,7 @@ export function DialogSelectServer() {
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("editServer", { busy: false })
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
@@ -441,7 +447,7 @@ export function DialogSelectServer() {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
@@ -494,8 +500,8 @@ export function DialogSelectServer() {
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
platform.setDefaultServerUrl?.(null)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
platform.setDefaultServer?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +557,7 @@ export function DialogSelectServer() {
|
||||
status={store.status[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -584,14 +590,14 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||
<Show when={canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||
<Show when={canDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -27,7 +26,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
@@ -37,6 +35,8 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||
import {
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
type PromptHistoryStoredEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit } from "./prompt-input/submit"
|
||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
@@ -61,6 +61,11 @@ interface PromptInputProps {
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
shouldQueue?: () => boolean
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
@@ -102,13 +107,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -154,9 +159,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
requestAnimationFrame(scrollCursorIntoView)
|
||||
}
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
@@ -209,7 +216,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = tabs().active()
|
||||
const active = activeFileTab()
|
||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const paths: string[] = []
|
||||
@@ -255,6 +262,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const motion = (value: number) => ({
|
||||
opacity: value,
|
||||
transform: `scale(${0.95 + value * 0.05})`,
|
||||
filter: `blur(${(1 - value) * 2}px)`,
|
||||
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
|
||||
})
|
||||
const buttons = createMemo(() => motion(buttonsSpring()))
|
||||
const shell = createMemo(() => motion(1 - buttonsSpring()))
|
||||
const control = createMemo(() => ({ height: "28px", ...buttons() }))
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -490,6 +506,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setComposing(false)
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setComposing(true)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
setComposing(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (composing()) return
|
||||
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||
})
|
||||
}
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
sync.data.agent
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
@@ -680,24 +708,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconcile = (input: Prompt) => {
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
return
|
||||
}
|
||||
|
||||
const dom = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => prompt.current(),
|
||||
(currentParts) => {
|
||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
||||
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
(parts) => {
|
||||
if (composing()) return
|
||||
reconcile(parts.filter((part) => part.type !== "image"))
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -921,6 +952,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setCurrentHistory("entries", next)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
|
||||
for (const item of edit.context) {
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
|
||||
setStore("mode", "normal")
|
||||
setStore("popover", null)
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, promptLength(edit.prompt))
|
||||
queueScroll()
|
||||
})
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const navigateHistory = (direction: "up" | "down") => {
|
||||
const result = navigatePromptHistory({
|
||||
direction,
|
||||
@@ -975,6 +1045,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
|
||||
@@ -1208,8 +1281,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
spellcheck={store.mode === "normal"}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
@@ -1260,11 +1333,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1302,11 +1371,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1364,10 +1429,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
...shell(),
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
@@ -1387,13 +1449,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={control()}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1411,13 +1467,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={control()}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1446,13 +1496,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1484,48 +1528,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={control()}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<RadioGroup
|
||||
options={["shell", "normal"] as const}
|
||||
current={store.mode}
|
||||
value={(mode) => mode}
|
||||
label={(mode) => (
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
openDelay={2000}
|
||||
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||
class="size-full flex items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
name={mode === "shell" ? "console" : "prompt"}
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-strong-base": store.mode === mode,
|
||||
"text-icon-weak": store.mode !== mode,
|
||||
}}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
)}
|
||||
onSelect={(mode) => mode && setMode(mode)}
|
||||
fill
|
||||
pad="none"
|
||||
class="w-[68px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
</Show>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
@@ -25,6 +25,145 @@ type PendingPrompt = {
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: ReturnType<typeof useSDK>["client"]
|
||||
globalSync: ReturnType<typeof useGlobalSync>
|
||||
sync: ReturnType<typeof useSync>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
before?: () => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
|
||||
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
setStore("session_status", input.draft.sessionID, { type: "busy" })
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
setStore("session_status", input.draft.sessionID, { type: "idle" })
|
||||
}
|
||||
|
||||
const wait = async () => {
|
||||
const ok = await input.before?.()
|
||||
if (ok === false) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const [head, ...tail] = text.split(" ")
|
||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||
setBusy()
|
||||
try {
|
||||
if (!(await wait())) {
|
||||
setIdle()
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images,
|
||||
text,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
const message: Message = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
variant: input.draft.variant,
|
||||
}
|
||||
|
||||
const add = () =>
|
||||
input.sync.session.optimistic.add({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
message,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const remove = () =>
|
||||
input.sync.session.optimistic.remove({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
|
||||
setBusy()
|
||||
add()
|
||||
|
||||
try {
|
||||
if (!(await wait())) {
|
||||
setIdle()
|
||||
remove()
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
sessionID: input.draft.sessionID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
remove()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
type PromptSubmitInput = {
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
@@ -41,6 +180,9 @@ type PromptSubmitInput = {
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
shouldQueue?: Accessor<boolean>
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
@@ -82,6 +224,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const [, setStore] = globalSync.child(sdk.directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const queued = pending.get(sessionID)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
@@ -116,6 +260,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -215,14 +365,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
const context = prompt.context.items().slice()
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
prompt.reset()
|
||||
@@ -243,6 +401,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext()
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
@@ -295,48 +462,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const context = prompt.context.items().slice()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
images,
|
||||
text,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
sessionDirectory,
|
||||
})
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const addOptimisticMessage = () =>
|
||||
sync.session.optimistic.add({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
message: optimisticMessage,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const removeOptimisticMessage = () =>
|
||||
const removeOptimisticMessage = () => {
|
||||
sync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -393,20 +531,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return true
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.promptAsync({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
void send().catch((err) => {
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
sync,
|
||||
globalSync,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(session.id)
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
|
||||
@@ -65,22 +65,26 @@ export function ServerRow(props: ServerRowProps) {
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
class="flex-1"
|
||||
class="flex-1 min-w-0"
|
||||
value={tooltipValue()}
|
||||
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
|
||||
placement="top-start"
|
||||
inactive={!truncated() && !props.conn.displayName}
|
||||
>
|
||||
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||
<div class="flex flex-col items-start">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||
<div class="flex flex-col items-start min-w-0 w-full">
|
||||
<div class="flex flex-row items-center gap-2 min-w-0 w-full">
|
||||
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show
|
||||
when={badge()}
|
||||
fallback={
|
||||
<Show when={props.status?.version}>
|
||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||
<span
|
||||
ref={versionRef}
|
||||
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||
>
|
||||
v{props.status?.version}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -27,14 +29,17 @@ function openSessionContext(args: {
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const params = useParams()
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabs().active() === "context") {
|
||||
if (tabState.activeTab() === "context") {
|
||||
tabs().close("context")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
|
||||
@@ -4,25 +4,21 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
@@ -112,12 +108,6 @@ const LINUX_APPS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
||||
type OpenIcon = OpenApp | "file-explorer"
|
||||
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||
|
||||
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
|
||||
|
||||
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||
if (typeof navigator !== "object") return "unknown"
|
||||
@@ -136,102 +126,14 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useSessionShare(args: {
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
currentSession: () =>
|
||||
| {
|
||||
share?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
sessionID: () => string | undefined
|
||||
projectDirectory: () => string
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
}) {
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
unshare: false,
|
||||
copied: false,
|
||||
timer: undefined as number | undefined,
|
||||
})
|
||||
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
|
||||
|
||||
createEffect(() => {
|
||||
const url = shareUrl()
|
||||
if (url) return
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState({ copied: false, timer: undefined })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
})
|
||||
|
||||
const shareSession = () => {
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.share) return
|
||||
setState("share", true)
|
||||
args.globalSDK.client.session
|
||||
.share({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.unshare) return
|
||||
setState("unshare", true)
|
||||
args.globalSDK.client.session
|
||||
.unshare({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
const copyLink = (onError: (error: unknown) => void) => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState("copied", true)
|
||||
const timer = window.setTimeout(() => {
|
||||
setState("copied", false)
|
||||
setState("timer", undefined)
|
||||
}, 3000)
|
||||
setState("timer", timer)
|
||||
})
|
||||
.catch(onError)
|
||||
}
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
args.platform.openLink(url)
|
||||
}
|
||||
|
||||
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
|
||||
}
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
@@ -245,12 +147,6 @@ export function SessionHeader() {
|
||||
return getFilename(projectDirectory())
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
|
||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
@@ -282,10 +178,7 @@ export function SessionHeader() {
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => {
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
.then((ok) => [app.id, ok] as const),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
@@ -361,14 +254,6 @@ export function SessionHeader() {
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
}
|
||||
|
||||
const share = useSessionShare({
|
||||
globalSDK,
|
||||
currentSession,
|
||||
sessionID: () => params.id,
|
||||
projectDirectory,
|
||||
platform,
|
||||
})
|
||||
|
||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||
|
||||
@@ -396,7 +281,9 @@ export function SessionHeader() {
|
||||
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
@@ -407,7 +294,6 @@ export function SessionHeader() {
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusPopover />
|
||||
<Show when={projectDirectory()}>
|
||||
<div class="hidden xl:flex items-center">
|
||||
<Show
|
||||
@@ -432,7 +318,7 @@ export function SessionHeader() {
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
|
||||
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
@@ -440,17 +326,13 @@ export function SessionHeader() {
|
||||
disabled={opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={opening()}
|
||||
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||
<Spinner class="size-3.5 text-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
|
||||
</Button>
|
||||
<div class="self-stretch w-px bg-border-weak-base" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
@@ -462,17 +344,20 @@ export function SessionHeader() {
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
disabled={opening()}
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
||||
{language.t("session.header.openIn")}
|
||||
</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
class="mt-1"
|
||||
value={current().id}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
@@ -489,8 +374,8 @@ export function SessionHeader() {
|
||||
openDir(o.id)
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<AppIcon id={o.icon} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
@@ -523,113 +408,10 @@ export function SessionHeader() {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
share.shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
shift={-64}
|
||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||
classList: {
|
||||
"rounded-r-none": share.shareUrl() !== undefined,
|
||||
"border-r-0": share.shareUrl() !== undefined,
|
||||
},
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={share.shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={share.shareSession}
|
||||
disabled={share.state.share}
|
||||
>
|
||||
{share.state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={share.shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={share.unshareSession}
|
||||
disabled={share.state.unshare}
|
||||
>
|
||||
{share.state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={share.viewShare}
|
||||
disabled={share.state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
share.state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
>
|
||||
<IconButton
|
||||
icon={share.state.copied ? "check" : "link"}
|
||||
variant="ghost"
|
||||
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
|
||||
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
||||
disabled={share.state.unshare}
|
||||
aria-label={
|
||||
share.state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
@@ -642,23 +424,7 @@ export function SessionHeader() {
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-bottom-partial"
|
||||
class="hidden group-hover/terminal-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -675,23 +441,7 @@ export function SessionHeader() {
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
|
||||
|
||||
interface NewSessionViewProps {
|
||||
worktree: string
|
||||
onWorktreeChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||
const terminal = useTerminal()
|
||||
@@ -53,21 +54,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
|
||||
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
||||
if (!element) return
|
||||
|
||||
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
return
|
||||
}
|
||||
element.focus()
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsAgents: Component = () => {
|
||||
// TODO: Replace this placeholder with full agents settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsCommands: Component = () => {
|
||||
// TODO: Replace this placeholder with full commands settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -113,6 +113,11 @@ export const SettingsGeneral: Component = () => {
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
||||
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
||||
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
@@ -170,10 +175,8 @@ export const SettingsGeneral: Component = () => {
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const AppearanceSection = () => (
|
||||
const GeneralSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
@@ -193,8 +196,70 @@ export const SettingsGeneral: Component = () => {
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.appearance.title")}
|
||||
description={language.t("settings.general.row.appearance.description")}
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.followup.title")}
|
||||
description={language.t("settings.general.row.followup.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-followup"
|
||||
options={followupOptions()}
|
||||
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "180px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
@@ -211,6 +276,7 @@ export const SettingsGeneral: Component = () => {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "220px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -271,50 +337,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
const FeedSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||
@@ -465,9 +487,9 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<AppearanceSection />
|
||||
<GeneralSection />
|
||||
|
||||
<FeedSection />
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
@@ -551,12 +573,12 @@ interface SettingsRowProps {
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
// TODO: Replace this placeholder with full MCP settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type PermissionAction = "allow" | "ask" | "deny"
|
||||
|
||||
type PermissionObject = Record<string, PermissionAction>
|
||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
||||
type PermissionMap = Record<string, PermissionValue>
|
||||
|
||||
type PermissionItem = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: "allow", label: "settings.permissions.action.allow" },
|
||||
{ value: "ask", label: "settings.permissions.action.ask" },
|
||||
{ value: "deny", label: "settings.permissions.action.deny" },
|
||||
] as const
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
id: "read",
|
||||
title: "settings.permissions.tool.read.title",
|
||||
description: "settings.permissions.tool.read.description",
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
title: "settings.permissions.tool.edit.title",
|
||||
description: "settings.permissions.tool.edit.description",
|
||||
},
|
||||
{
|
||||
id: "glob",
|
||||
title: "settings.permissions.tool.glob.title",
|
||||
description: "settings.permissions.tool.glob.description",
|
||||
},
|
||||
{
|
||||
id: "grep",
|
||||
title: "settings.permissions.tool.grep.title",
|
||||
description: "settings.permissions.tool.grep.description",
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
title: "settings.permissions.tool.list.title",
|
||||
description: "settings.permissions.tool.list.description",
|
||||
},
|
||||
{
|
||||
id: "bash",
|
||||
title: "settings.permissions.tool.bash.title",
|
||||
description: "settings.permissions.tool.bash.description",
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
title: "settings.permissions.tool.task.title",
|
||||
description: "settings.permissions.tool.task.description",
|
||||
},
|
||||
{
|
||||
id: "skill",
|
||||
title: "settings.permissions.tool.skill.title",
|
||||
description: "settings.permissions.tool.skill.description",
|
||||
},
|
||||
{
|
||||
id: "lsp",
|
||||
title: "settings.permissions.tool.lsp.title",
|
||||
description: "settings.permissions.tool.lsp.description",
|
||||
},
|
||||
{
|
||||
id: "todoread",
|
||||
title: "settings.permissions.tool.todoread.title",
|
||||
description: "settings.permissions.tool.todoread.description",
|
||||
},
|
||||
{
|
||||
id: "todowrite",
|
||||
title: "settings.permissions.tool.todowrite.title",
|
||||
description: "settings.permissions.tool.todowrite.description",
|
||||
},
|
||||
{
|
||||
id: "webfetch",
|
||||
title: "settings.permissions.tool.webfetch.title",
|
||||
description: "settings.permissions.tool.webfetch.description",
|
||||
},
|
||||
{
|
||||
id: "websearch",
|
||||
title: "settings.permissions.tool.websearch.title",
|
||||
description: "settings.permissions.tool.websearch.description",
|
||||
},
|
||||
{
|
||||
id: "codesearch",
|
||||
title: "settings.permissions.tool.codesearch.title",
|
||||
description: "settings.permissions.tool.codesearch.description",
|
||||
},
|
||||
{
|
||||
id: "external_directory",
|
||||
title: "settings.permissions.tool.external_directory.title",
|
||||
description: "settings.permissions.tool.external_directory.description",
|
||||
},
|
||||
{
|
||||
id: "doom_loop",
|
||||
title: "settings.permissions.tool.doom_loop.title",
|
||||
description: "settings.permissions.tool.doom_loop.description",
|
||||
},
|
||||
] as const
|
||||
|
||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
||||
|
||||
function toMap(value: unknown): PermissionMap {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
||||
|
||||
const action = getAction(value)
|
||||
if (action) return { "*": action }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getAction(value: unknown): PermissionAction | undefined {
|
||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
||||
return
|
||||
}
|
||||
|
||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
||||
const action = getAction(value)
|
||||
if (action) return action
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
|
||||
return getAction((value as Record<string, unknown>)["*"])
|
||||
}
|
||||
|
||||
export const SettingsPermissions: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const actions = createMemo(
|
||||
(): Array<{ value: PermissionAction; label: string }> =>
|
||||
ACTIONS.map((action) => ({
|
||||
value: action.value,
|
||||
label: language.t(action.label),
|
||||
})),
|
||||
)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
return toMap(globalSync.data.config.permission)
|
||||
})
|
||||
|
||||
const actionFor = (id: string): PermissionAction => {
|
||||
const value = permission()[id]
|
||||
const direct = getRuleDefault(value)
|
||||
if (direct) return direct
|
||||
|
||||
const wildcard = getRuleDefault(permission()["*"])
|
||||
if (wildcard) return wildcard
|
||||
|
||||
return "allow"
|
||||
}
|
||||
|
||||
const setPermission = async (id: string, action: PermissionAction) => {
|
||||
const before = globalSync.data.config.permission
|
||||
const map = toMap(before)
|
||||
const existing = map[id]
|
||||
|
||||
const nextValue =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||
|
||||
const rollback = (err: unknown) => {
|
||||
globalSync.set("config", "permission", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
||||
}
|
||||
|
||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||
<For each={ITEMS}>
|
||||
{(item) => (
|
||||
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
|
||||
<Select
|
||||
options={actions()}
|
||||
current={actions().find((o) => o.value === actionFor(item.id))}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
@@ -53,7 +53,8 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
@@ -168,7 +169,7 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
@@ -176,16 +177,18 @@ export function StatusPopover() {
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, fetcher)
|
||||
const health = useServerHealth(servers)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const plugins = createMemo(() =>
|
||||
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const overallHealthy = createMemo(() => {
|
||||
@@ -199,18 +202,23 @@ export function StatusPopover() {
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class: "titlebar-icon w-6 h-6 p-0 box-border",
|
||||
class: "titlebar-icon w-8 h-6 p-0 box-border",
|
||||
"aria-label": language.t("status.popover.trigger"),
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex size-4 items-center justify-center">
|
||||
<div class="relative size-4">
|
||||
<div class="badge-mask-tight size-4 flex items-center justify-center">
|
||||
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
|
||||
@@ -160,6 +161,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const probe = terminalProbe(id)
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
@@ -326,6 +328,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
probe.init()
|
||||
cleanups.push(() => probe.drop())
|
||||
|
||||
const run = async () => {
|
||||
const loaded = await loadGhostty()
|
||||
if (disposed) return
|
||||
@@ -353,7 +358,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data, done) => t.write(data, done))
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
probe.render(data)
|
||||
probe.settle()
|
||||
done?.()
|
||||
}),
|
||||
)
|
||||
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
@@ -441,10 +452,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
startResize()
|
||||
}
|
||||
|
||||
// t.onScroll((ydisp) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
@@ -452,7 +459,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.username = server.current?.http.username ?? "opencode"
|
||||
url.password = server.current?.http.password ?? ""
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
@@ -460,6 +467,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
ws = socket
|
||||
|
||||
const handleOpen = () => {
|
||||
probe.connect()
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
@@ -560,6 +568,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
{...{ [terminalAttr]: id }}
|
||||
data-prevent-autofocus
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -58,6 +58,12 @@ export function Titlebar() {
|
||||
})
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const creating = createMemo(() => {
|
||||
if (!params.dir) return false
|
||||
if (params.id) return false
|
||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||
return parts.at(-1) === "session"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = path()
|
||||
@@ -206,19 +212,7 @@ export function Titlebar() {
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
|
||||
class="group-hover/sidebar-toggle:hidden"
|
||||
/>
|
||||
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
|
||||
class="hidden group-active/sidebar-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
@@ -231,13 +225,14 @@ export function Titlebar() {
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="new-session"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
@@ -282,7 +277,7 @@ export function Titlebar() {
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
{!tauriApi() && <div class="w-36 shrink-0" />}
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
pickDirectoriesToEvict,
|
||||
} from "./global-sync"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
@@ -161,6 +162,7 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -402,6 +404,3 @@ export function useGlobalSync() {
|
||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
clearSessionPrefetchDirectory,
|
||||
getSessionPrefetch,
|
||||
runSessionPrefetch,
|
||||
setSessionPrefetch,
|
||||
} from "./session-prefetch"
|
||||
|
||||
describe("session prefetch", () => {
|
||||
test("stores and clears message metadata by directory", () => {
|
||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||
clearSessionPrefetch("/tmp/b", ["ses_1"])
|
||||
|
||||
setSessionPrefetch({
|
||||
directory: "/tmp/a",
|
||||
sessionID: "ses_1",
|
||||
limit: 200,
|
||||
complete: false,
|
||||
at: 123,
|
||||
})
|
||||
|
||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
|
||||
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
||||
|
||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||
|
||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dedupes inflight work", async () => {
|
||||
clearSessionPrefetch("/tmp/c", ["ses_2"])
|
||||
|
||||
let calls = 0
|
||||
const run = () =>
|
||||
runSessionPrefetch({
|
||||
directory: "/tmp/c",
|
||||
sessionID: "ses_2",
|
||||
task: async () => {
|
||||
calls += 1
|
||||
return { limit: 100, complete: true, at: 456 }
|
||||
},
|
||||
})
|
||||
|
||||
const [a, b] = await Promise.all([run(), run()])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(a).toEqual({ limit: 100, complete: true, at: 456 })
|
||||
expect(b).toEqual({ limit: 100, complete: true, at: 456 })
|
||||
})
|
||||
|
||||
test("clears a whole directory", () => {
|
||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
|
||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
|
||||
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
|
||||
|
||||
clearSessionPrefetchDirectory("/tmp/d")
|
||||
|
||||
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
||||
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
||||
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
|
||||
})
|
||||
})
|
||||
85
packages/app/src/context/global-sync/session-prefetch.ts
Normal file
85
packages/app/src/context/global-sync/session-prefetch.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
|
||||
|
||||
export const SESSION_PREFETCH_TTL = 15_000
|
||||
|
||||
type Meta = {
|
||||
limit: number
|
||||
complete: boolean
|
||||
at: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, Meta>()
|
||||
const inflight = new Map<string, Promise<Meta | undefined>>()
|
||||
const rev = new Map<string, number>()
|
||||
|
||||
const version = (id: string) => rev.get(id) ?? 0
|
||||
|
||||
export function getSessionPrefetch(directory: string, sessionID: string) {
|
||||
return cache.get(key(directory, sessionID))
|
||||
}
|
||||
|
||||
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
|
||||
return inflight.get(key(directory, sessionID))
|
||||
}
|
||||
|
||||
export function clearSessionPrefetchInflight() {
|
||||
inflight.clear()
|
||||
}
|
||||
|
||||
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
|
||||
return version(key(directory, sessionID)) === value
|
||||
}
|
||||
|
||||
export function runSessionPrefetch(input: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
task: (value: number) => Promise<Meta | undefined>
|
||||
}) {
|
||||
const id = key(input.directory, input.sessionID)
|
||||
const pending = inflight.get(id)
|
||||
if (pending) return pending
|
||||
|
||||
const value = version(id)
|
||||
|
||||
const promise = input.task(value).finally(() => {
|
||||
if (inflight.get(id) === promise) inflight.delete(id)
|
||||
})
|
||||
|
||||
inflight.set(id, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function setSessionPrefetch(input: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
limit: number
|
||||
complete: boolean
|
||||
at?: number
|
||||
}) {
|
||||
cache.set(key(input.directory, input.sessionID), {
|
||||
limit: input.limit,
|
||||
complete: input.complete,
|
||||
at: input.at ?? Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
|
||||
for (const sessionID of sessionIDs) {
|
||||
if (!sessionID) continue
|
||||
const id = key(directory, sessionID)
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionPrefetchDirectory(directory: string) {
|
||||
const prefix = `${directory}\n`
|
||||
const keys = new Set([...cache.keys(), ...inflight.keys()])
|
||||
for (const id of keys) {
|
||||
if (!id.startsWith(prefix)) continue
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
const settings = useSettings()
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||
const [range, setRange] = createStore({
|
||||
from: undefined as string | undefined,
|
||||
to: undefined as string | undefined,
|
||||
})
|
||||
const state = { started: false }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
|
||||
if (previous === platform.version) return
|
||||
|
||||
setFrom(previous)
|
||||
setTo(platform.version)
|
||||
setRange({ from: previous, to: platform.version })
|
||||
start(previous)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
from,
|
||||
to,
|
||||
from: () => range.from,
|
||||
to: () => range.to,
|
||||
get last() {
|
||||
return store.version
|
||||
},
|
||||
|
||||
@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
open: createMemo(() => s().reviewOpen ?? []),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
const next = Array.from(new Set(open))
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: next,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, next)) return
|
||||
setStore("sessionView", session, "reviewOpen", next)
|
||||
},
|
||||
openPath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: open,
|
||||
reviewOpen: [path],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, open)) return
|
||||
setStore("sessionView", session, "reviewOpen", open)
|
||||
if (!current.reviewOpen) {
|
||||
setStore("sessionView", session, "reviewOpen", [path])
|
||||
return
|
||||
}
|
||||
|
||||
if (current.reviewOpen.includes(path)) return
|
||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||
},
|
||||
closePath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current) return
|
||||
|
||||
const index = current.indexOf(path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionView",
|
||||
session,
|
||||
"reviewOpen",
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
togglePath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current || !current.includes(path)) {
|
||||
this.openPath(path)
|
||||
return
|
||||
}
|
||||
|
||||
this.closePath(path)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
type NotificationIndexItem = {
|
||||
directory?: string
|
||||
session?: string
|
||||
viewed: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
||||
const sessionAll = new Map<string, T[]>()
|
||||
const sessionUnseen = new Map<string, T[]>()
|
||||
const sessionUnseenCount = new Map<string, number>()
|
||||
const sessionUnseenHasError = new Map<string, boolean>()
|
||||
const projectAll = new Map<string, T[]>()
|
||||
const projectUnseen = new Map<string, T[]>()
|
||||
const projectUnseenCount = new Map<string, number>()
|
||||
const projectUnseenHasError = new Map<string, boolean>()
|
||||
|
||||
for (const notification of list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const all = sessionAll.get(session)
|
||||
if (all) all.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
|
||||
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
||||
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const all = projectAll.get(directory)
|
||||
if (all) all.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
|
||||
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
||||
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
unseenCount: sessionUnseenCount,
|
||||
unseenHasError: sessionUnseenHasError,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
unseenCount: projectUnseenCount,
|
||||
unseenHasError: projectUnseenHasError,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type Notification = {
|
||||
type: "turn-complete" | "error"
|
||||
session: string
|
||||
directory: string
|
||||
viewed: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "turn-complete",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
const error = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "error",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
describe("buildNotificationIndex", () => {
|
||||
test("builds unseen counts and unseen error flags", () => {
|
||||
const list = [
|
||||
turn("s1", "d1", false),
|
||||
error("s1", "d1", false),
|
||||
turn("s1", "d1", true),
|
||||
turn("s2", "d1", false),
|
||||
error("s3", "d2", true),
|
||||
]
|
||||
|
||||
const index = buildNotificationIndex(list)
|
||||
|
||||
expect(index.session.all.get("s1")?.length).toBe(3)
|
||||
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
||||
expect(index.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
||||
|
||||
expect(index.session.unseenCount.get("s2")).toBe(1)
|
||||
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
||||
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
||||
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
||||
|
||||
expect(index.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
||||
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
||||
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
||||
})
|
||||
|
||||
test("updates selectors after viewed transitions", () => {
|
||||
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
||||
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
||||
|
||||
const before = buildNotificationIndex(list)
|
||||
const after = buildNotificationIndex(next)
|
||||
|
||||
expect(before.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
||||
expect(before.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
||||
|
||||
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
||||
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
||||
expect(after.project.unseenCount.get("d1")).toBe(1)
|
||||
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { ServerConnection } from "./server"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -58,10 +59,10 @@ export type Platform = {
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServerUrl?(): Promise<string | null>
|
||||
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { checkServerHealth } from "@/utils/server-health"
|
||||
import { useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
@@ -96,7 +95,7 @@ export namespace ServerConnection {
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||
const platform = usePlatform()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
||||
|
||||
createEffect(() => {
|
||||
const current_ = current()
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Settings {
|
||||
general: {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
followup: "queue" | "steer"
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
@@ -45,6 +46,7 @@ const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
@@ -126,6 +128,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setReleaseNotes(value: boolean) {
|
||||
setStore("general", "releaseNotes", value)
|
||||
},
|
||||
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
setStore("general", "followup", value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
|
||||
@@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -160,6 +166,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
@@ -217,11 +224,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.complete)
|
||||
setSessionPrefetch({
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit,
|
||||
complete: next.complete,
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
setMeta("loading", key, false)
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
if (!tracked(input.directory, input.sessionID)) {
|
||||
delete draft.loading[key]
|
||||
return
|
||||
}
|
||||
draft.loading[key] = false
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,54 +300,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
return runInflight(inflight, key, async () => {
|
||||
const pending = getSessionPrefetchPromise(directory, sessionID)
|
||||
if (pending) {
|
||||
await pending
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const messagesReq = loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
cached && !opts?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
await Promise.all([sessionReq, messagesReq])
|
||||
})
|
||||
|
||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
||||
},
|
||||
async diff(sessionID: string) {
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
@@ -337,7 +385,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string) {
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
@@ -348,7 +396,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
return
|
||||
if (!opts?.force) return
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
|
||||
@@ -98,6 +98,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
const getDefaultUrl = () => {
|
||||
const lsDefault = readDefaultServerUrl()
|
||||
if (lsDefault) return lsDefault
|
||||
return getCurrentUrl()
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
@@ -106,26 +119,20 @@ const platform: Platform = {
|
||||
forward,
|
||||
restart,
|
||||
notify,
|
||||
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
||||
setDefaultServerUrl: writeDefaultServerUrl,
|
||||
getDefaultServer: async () => {
|
||||
const stored = readDefaultServerUrl()
|
||||
return stored ? ServerConnection.Key.make(stored) : null
|
||||
},
|
||||
setDefaultServer: writeDefaultServerUrl,
|
||||
}
|
||||
|
||||
const defaultUrl = iife(() => {
|
||||
const lsDefault = readDefaultServerUrl()
|
||||
if (lsDefault) return lsDefault
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
})
|
||||
|
||||
if (root instanceof HTMLElement) {
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
||||
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
|
||||
export function useProviders() {
|
||||
const globalSync = useGlobalSync()
|
||||
const params = useParams()
|
||||
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = createMemo(() => {
|
||||
if (currentDirectory()) {
|
||||
const [projectStore] = globalSync.child(currentDirectory())
|
||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
})
|
||||
const connectedIDs = createMemo(() => new Set(providers().connected))
|
||||
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
|
||||
const paid = createMemo(() =>
|
||||
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
|
||||
)
|
||||
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
|
||||
}
|
||||
return {
|
||||
all: createMemo(() => providers().all),
|
||||
default: createMemo(() => providers().default),
|
||||
popular,
|
||||
connected,
|
||||
paid,
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter((p) => connected.has(p.id))
|
||||
},
|
||||
paid: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter(
|
||||
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ export const dict = {
|
||||
"dialog.model.empty": "لا توجد نتائج للنماذج",
|
||||
"dialog.model.manage": "إدارة النماذج",
|
||||
"dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.",
|
||||
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
|
||||
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
|
||||
@@ -288,6 +289,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "تعذر الاتصال بالخادم",
|
||||
"dialog.server.add.checking": "جارٍ التحقق...",
|
||||
"dialog.server.add.button": "إضافة خادم",
|
||||
"dialog.server.add.name": "اسم الخادم (اختياري)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "اسم المستخدم (اختياري)",
|
||||
"dialog.server.add.password": "كلمة المرور (اختياري)",
|
||||
"dialog.server.edit.title": "تحرير الخادم",
|
||||
"dialog.server.default.title": "الخادم الافتراضي",
|
||||
"dialog.server.default.description":
|
||||
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
|
||||
@@ -358,6 +364,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "لغة",
|
||||
"toast.language.description": "تم التبديل إلى {{language}}",
|
||||
"toast.theme.title": "تم تبديل السمة",
|
||||
@@ -444,8 +451,11 @@ export const dict = {
|
||||
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
|
||||
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
|
||||
"session.review.noChanges": "لا توجد تغييرات",
|
||||
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
|
||||
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
|
||||
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
||||
"session.files.all": "كل الملفات",
|
||||
"session.files.empty": "لا توجد ملفات",
|
||||
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
|
||||
"session.messages.renderEarlier": "عرض الرسائل السابقة",
|
||||
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
|
||||
@@ -456,6 +466,17 @@ export const dict = {
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
|
||||
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
|
||||
"session.followupDock.sendNow": "إرسال الآن",
|
||||
"session.followupDock.edit": "تحرير",
|
||||
"session.followupDock.collapse": "طي الرسائل المنتظرة",
|
||||
"session.followupDock.expand": "توسيع الرسائل المنتظرة",
|
||||
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
|
||||
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
||||
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.restore": "استعادة الرسالة",
|
||||
"session.new.title": "ابنِ أي شيء",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
@@ -538,10 +559,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
|
||||
"settings.general.row.appearance.title": "المظهر",
|
||||
"settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
|
||||
"settings.general.row.colorScheme.title": "مخطط الألوان",
|
||||
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
|
||||
"settings.general.row.theme.title": "السمة",
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
||||
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
||||
"settings.general.row.followup.option.steer": "توجيه",
|
||||
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
|
||||
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Nenhum resultado de modelo",
|
||||
"dialog.model.manage": "Gerenciar modelos",
|
||||
"dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.",
|
||||
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
|
||||
"dialog.provider.viewAll": "Ver mais provedores",
|
||||
@@ -288,6 +289,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Não foi possível conectar ao servidor",
|
||||
"dialog.server.add.checking": "Verificando...",
|
||||
"dialog.server.add.button": "Adicionar",
|
||||
"dialog.server.add.name": "Nome do servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nome de usuário (opcional)",
|
||||
"dialog.server.add.password": "Senha (opcional)",
|
||||
"dialog.server.edit.title": "Editar servidor",
|
||||
"dialog.server.default.title": "Servidor padrão",
|
||||
"dialog.server.default.description":
|
||||
"Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.",
|
||||
@@ -359,6 +365,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Idioma",
|
||||
"toast.language.description": "Alterado para {{language}}",
|
||||
"toast.theme.title": "Tema alterado",
|
||||
@@ -446,9 +453,13 @@ export const dict = {
|
||||
"session.review.change.other": "Alterações",
|
||||
"session.review.loadingChanges": "Carregando alterações...",
|
||||
"session.review.empty": "Nenhuma alteração nesta sessão ainda",
|
||||
"session.review.noVcs": "Nenhum Sistema de Controle de Versão Git detectado, alterações não exibidas",
|
||||
"session.review.noSnapshot":
|
||||
"O rastreamento de snapshot está desabilitado na configuração, então as alterações da sessão estão indisponíveis",
|
||||
"session.review.noChanges": "Sem alterações",
|
||||
"session.files.selectToOpen": "Selecione um arquivo para abrir",
|
||||
"session.files.all": "Todos os arquivos",
|
||||
"session.files.empty": "Nenhum arquivo",
|
||||
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
|
||||
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
|
||||
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
|
||||
@@ -459,6 +470,17 @@ export const dict = {
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.followupDock.summary.one": "{{count}} mensagem na fila",
|
||||
"session.followupDock.summary.other": "{{count}} mensagens na fila",
|
||||
"session.followupDock.sendNow": "Enviar agora",
|
||||
"session.followupDock.edit": "Editar",
|
||||
"session.followupDock.collapse": "Recolher mensagens na fila",
|
||||
"session.followupDock.expand": "Expandir mensagens na fila",
|
||||
"session.revertDock.summary.one": "{{count}} mensagem revertida",
|
||||
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
||||
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||
"session.revertDock.restore": "Restaurar mensagem",
|
||||
"session.new.title": "Crie qualquer coisa",
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
@@ -544,10 +566,19 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
|
||||
"settings.general.row.appearance.title": "Aparência",
|
||||
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
|
||||
"settings.general.row.colorScheme.title": "Esquema de cores",
|
||||
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
"settings.general.row.followup.description":
|
||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||
"settings.general.row.followup.option.queue": "Fila",
|
||||
"settings.general.row.followup.option.steer": "Orientar",
|
||||
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
|
||||
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
|
||||
|
||||
@@ -113,6 +113,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Nema rezultata za modele",
|
||||
"dialog.model.manage": "Upravljaj modelima",
|
||||
"dialog.model.manage.description": "Prilagodi koji se modeli prikazuju u izborniku modela.",
|
||||
"dialog.model.manage.provider.toggle": "Uključi/isključi sve {{provider}} modele",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
|
||||
@@ -315,6 +316,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Nije moguće povezati se na server",
|
||||
"dialog.server.add.checking": "Provjera...",
|
||||
"dialog.server.add.button": "Dodaj server",
|
||||
"dialog.server.add.name": "Ime servera (opcionalno)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Korisničko ime (opcionalno)",
|
||||
"dialog.server.add.password": "Lozinka (opcionalno)",
|
||||
"dialog.server.edit.title": "Uredi server",
|
||||
"dialog.server.default.title": "Podrazumijevani server",
|
||||
"dialog.server.default.description":
|
||||
"Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potreban je restart.",
|
||||
@@ -393,6 +399,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Jezik",
|
||||
"toast.language.description": "Prebačeno na {{language}}",
|
||||
@@ -498,10 +505,14 @@ export const dict = {
|
||||
"session.review.change.other": "Izmjene",
|
||||
"session.review.loadingChanges": "Učitavanje izmjena...",
|
||||
"session.review.empty": "Još nema izmjena u ovoj sesiji",
|
||||
"session.review.noVcs": "Nije detektovan Git sistem kontrole verzija, promjene se ne prikazuju",
|
||||
"session.review.noSnapshot":
|
||||
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
|
||||
"session.review.noChanges": "Nema izmjena",
|
||||
|
||||
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
|
||||
"session.files.all": "Sve datoteke",
|
||||
"session.files.empty": "Nema datoteka",
|
||||
"session.files.binaryContent": "Binarna datoteka (sadržaj se ne može prikazati)",
|
||||
|
||||
"session.messages.renderEarlier": "Prikaži ranije poruke",
|
||||
@@ -514,6 +525,17 @@ export const dict = {
|
||||
"session.todo.title": "Zadaci",
|
||||
"session.todo.collapse": "Sažmi",
|
||||
"session.todo.expand": "Proširi",
|
||||
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
|
||||
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
|
||||
"session.followupDock.sendNow": "Pošalji sada",
|
||||
"session.followupDock.edit": "Uredi",
|
||||
"session.followupDock.collapse": "Sažmi poruke na čekanju",
|
||||
"session.followupDock.expand": "Proširi poruke na čekanju",
|
||||
"session.revertDock.summary.one": "{{count}} vraćena poruka",
|
||||
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||
"session.revertDock.restore": "Vrati poruku",
|
||||
|
||||
"session.new.title": "Napravi bilo šta",
|
||||
"session.new.worktree.main": "Glavna grana",
|
||||
@@ -609,10 +631,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u",
|
||||
"settings.general.row.appearance.title": "Izgled",
|
||||
"settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju",
|
||||
"settings.general.row.colorScheme.title": "Šema boja",
|
||||
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||
"settings.general.row.followup.option.steer": "Usmjeri",
|
||||
"settings.general.row.reasoningSummaries.title": "Prikaži sažetke rasuđivanja",
|
||||
"settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
|
||||
@@ -113,6 +113,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Ingen modeller fundet",
|
||||
"dialog.model.manage": "Administrer modeller",
|
||||
"dialog.model.manage.description": "Tilpas hvilke modeller der vises i modelvælgeren.",
|
||||
"dialog.model.manage.provider.toggle": "Skift alle {{provider}}-modeller",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
|
||||
@@ -313,6 +314,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Kunne ikke forbinde til server",
|
||||
"dialog.server.add.checking": "Tjekker...",
|
||||
"dialog.server.add.button": "Tilføj server",
|
||||
"dialog.server.add.name": "Servernavn (valgfrit)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Brugernavn (valgfrit)",
|
||||
"dialog.server.add.password": "Adgangskode (valgfrit)",
|
||||
"dialog.server.edit.title": "Rediger server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
|
||||
@@ -391,6 +397,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Sprog",
|
||||
"toast.language.description": "Skiftede til {{language}}",
|
||||
@@ -495,9 +502,13 @@ export const dict = {
|
||||
"session.review.change.other": "Ændringer",
|
||||
"session.review.loadingChanges": "Indlæser ændringer...",
|
||||
"session.review.empty": "Ingen ændringer i denne session endnu",
|
||||
"session.review.noVcs": "Intet Git versionsstyringssystem fundet, ændringer vises ikke",
|
||||
"session.review.noSnapshot":
|
||||
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
|
||||
"session.review.noChanges": "Ingen ændringer",
|
||||
"session.files.selectToOpen": "Vælg en fil at åbne",
|
||||
"session.files.all": "Alle filer",
|
||||
"session.files.empty": "Ingen filer",
|
||||
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
|
||||
"session.messages.renderEarlier": "Vis tidligere beskeder",
|
||||
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
|
||||
@@ -509,6 +520,17 @@ export const dict = {
|
||||
"session.todo.title": "Opgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Udvid",
|
||||
"session.followupDock.summary.one": "{{count}} besked i kø",
|
||||
"session.followupDock.summary.other": "{{count}} beskeder i kø",
|
||||
"session.followupDock.sendNow": "Send nu",
|
||||
"session.followupDock.edit": "Rediger",
|
||||
"session.followupDock.collapse": "Skjul beskeder i kø",
|
||||
"session.followupDock.expand": "Udvid beskeder i kø",
|
||||
"session.revertDock.summary.one": "{{count}} tilbagerullet besked",
|
||||
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
|
||||
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
|
||||
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
|
||||
"session.revertDock.restore": "Gendan besked",
|
||||
|
||||
"session.new.title": "Byg hvad som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
@@ -604,10 +626,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Ændr visningssproget for OpenCode",
|
||||
"settings.general.row.appearance.title": "Udseende",
|
||||
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
|
||||
"settings.general.row.colorScheme.title": "Farveskema",
|
||||
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
||||
"settings.general.row.font.title": "Skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
"settings.general.row.followup.option.steer": "Styr",
|
||||
"settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter",
|
||||
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
|
||||
|
||||
@@ -108,6 +108,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Keine Modellergebnisse",
|
||||
"dialog.model.manage": "Modelle verwalten",
|
||||
"dialog.model.manage.description": "Anpassen, welche Modelle in der Modellauswahl erscheinen.",
|
||||
"dialog.model.manage.provider.toggle": "Alle {{provider}}-Modelle umschalten",
|
||||
"dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Weitere Modelle von beliebten Anbietern hinzufügen",
|
||||
"dialog.provider.viewAll": "Mehr Anbieter anzeigen",
|
||||
@@ -294,6 +295,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
|
||||
"dialog.server.add.checking": "Prüfen...",
|
||||
"dialog.server.add.button": "Server hinzufügen",
|
||||
"dialog.server.add.name": "Servername (optional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Benutzername (optional)",
|
||||
"dialog.server.add.password": "Passwort (optional)",
|
||||
"dialog.server.edit.title": "Server bearbeiten",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
|
||||
@@ -366,6 +372,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Sprache",
|
||||
"toast.language.description": "Zu {{language}} gewechselt",
|
||||
"toast.theme.title": "Thema gewechselt",
|
||||
@@ -454,9 +461,13 @@ export const dict = {
|
||||
"session.review.change.other": "Änderungen",
|
||||
"session.review.loadingChanges": "Lade Änderungen...",
|
||||
"session.review.empty": "Noch keine Änderungen in dieser Sitzung",
|
||||
"session.review.noVcs": "Kein Git-Versionskontrollsystem erkannt, Änderungen werden nicht angezeigt",
|
||||
"session.review.noSnapshot":
|
||||
"Snapshot-Tracking ist in der Konfiguration deaktiviert, daher sind Sitzungsänderungen nicht verfügbar",
|
||||
"session.review.noChanges": "Keine Änderungen",
|
||||
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
|
||||
"session.files.all": "Alle Dateien",
|
||||
"session.files.empty": "Keine Dateien",
|
||||
"session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
|
||||
"session.messages.renderEarlier": "Frühere Nachrichten rendern",
|
||||
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
|
||||
@@ -467,6 +478,17 @@ export const dict = {
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange",
|
||||
"session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange",
|
||||
"session.followupDock.sendNow": "Jetzt senden",
|
||||
"session.followupDock.edit": "Bearbeiten",
|
||||
"session.followupDock.collapse": "Warteschlange einklappen",
|
||||
"session.followupDock.expand": "Warteschlange ausklappen",
|
||||
"session.revertDock.summary.one": "{{count}} zurückgesetzte Nachricht",
|
||||
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
|
||||
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
|
||||
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
||||
"session.revertDock.restore": "Nachricht wiederherstellen",
|
||||
"session.new.title": "Baue, was du willst",
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
@@ -553,10 +575,21 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern",
|
||||
"settings.general.row.appearance.title": "Erscheinungsbild",
|
||||
"settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht",
|
||||
"settings.general.row.colorScheme.title": "Farbschema",
|
||||
"settings.general.row.colorScheme.description":
|
||||
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
|
||||
"settings.general.row.theme.title": "Thema",
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||
"settings.general.row.followup.description":
|
||||
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
|
||||
"settings.general.row.followup.option.queue": "Warteschlange",
|
||||
"settings.general.row.followup.option.steer": "Steuern",
|
||||
"settings.general.row.reasoningSummaries.title": "Reasoning-Zusammenfassungen anzeigen",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Zusammenfassungen des Modell-Reasonings in der Timeline anzeigen",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
|
||||
@@ -530,6 +530,17 @@ export const dict = {
|
||||
"session.todo.title": "Todos",
|
||||
"session.todo.collapse": "Collapse",
|
||||
"session.todo.expand": "Expand",
|
||||
"session.followupDock.summary.one": "{{count}} queued message",
|
||||
"session.followupDock.summary.other": "{{count}} queued messages",
|
||||
"session.followupDock.sendNow": "Send now",
|
||||
"session.followupDock.edit": "Edit",
|
||||
"session.followupDock.collapse": "Collapse queued messages",
|
||||
"session.followupDock.expand": "Expand queued messages",
|
||||
"session.revertDock.summary.one": "{{count}} rolled back message",
|
||||
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
||||
"session.revertDock.collapse": "Collapse rolled back messages",
|
||||
"session.revertDock.expand": "Expand rolled back messages",
|
||||
"session.revertDock.restore": "Restore message",
|
||||
|
||||
"session.new.title": "Build anything",
|
||||
"session.new.worktree.main": "Main branch",
|
||||
@@ -633,10 +644,16 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
|
||||
"settings.general.row.colorScheme.title": "Color scheme",
|
||||
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
|
||||
"settings.general.row.theme.title": "Theme",
|
||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.followup.title": "Follow-up behavior",
|
||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
|
||||
@@ -113,6 +113,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Sin resultados de modelos",
|
||||
"dialog.model.manage": "Gestionar modelos",
|
||||
"dialog.model.manage.description": "Personalizar qué modelos aparecen en el selector de modelos.",
|
||||
"dialog.model.manage.provider.toggle": "Alternar todos los modelos de {{provider}}",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos proporcionados por OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Añadir más modelos de proveedores populares",
|
||||
@@ -314,6 +315,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "No se pudo conectar al servidor",
|
||||
"dialog.server.add.checking": "Comprobando...",
|
||||
"dialog.server.add.button": "Añadir servidor",
|
||||
"dialog.server.add.name": "Nombre del servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nombre de usuario (opcional)",
|
||||
"dialog.server.add.password": "Contraseña (opcional)",
|
||||
"dialog.server.edit.title": "Editar servidor",
|
||||
"dialog.server.default.title": "Servidor predeterminado",
|
||||
"dialog.server.default.description":
|
||||
"Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
|
||||
@@ -393,6 +399,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Idioma",
|
||||
"toast.language.description": "Cambiado a {{language}}",
|
||||
@@ -499,10 +506,14 @@ export const dict = {
|
||||
"session.review.change.other": "Cambios",
|
||||
"session.review.loadingChanges": "Cargando cambios...",
|
||||
"session.review.empty": "No hay cambios en esta sesión aún",
|
||||
"session.review.noVcs": "No se detectó Sistema de Control de Versiones Git, los cambios no se muestran",
|
||||
"session.review.noSnapshot":
|
||||
"El seguimiento de instantáneas está deshabilitado en la configuración, por lo que los cambios de sesión no están disponibles",
|
||||
"session.review.noChanges": "Sin cambios",
|
||||
|
||||
"session.files.selectToOpen": "Selecciona un archivo para abrir",
|
||||
"session.files.all": "Todos los archivos",
|
||||
"session.files.empty": "Sin archivos",
|
||||
"session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
|
||||
|
||||
"session.messages.renderEarlier": "Renderizar mensajes anteriores",
|
||||
@@ -515,6 +526,17 @@ export const dict = {
|
||||
"session.todo.title": "Tareas",
|
||||
"session.todo.collapse": "Contraer",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.followupDock.summary.one": "{{count}} mensaje en cola",
|
||||
"session.followupDock.summary.other": "{{count}} mensajes en cola",
|
||||
"session.followupDock.sendNow": "Enviar ahora",
|
||||
"session.followupDock.edit": "Editar",
|
||||
"session.followupDock.collapse": "Contraer mensajes en cola",
|
||||
"session.followupDock.expand": "Expandir mensajes en cola",
|
||||
"session.revertDock.summary.one": "{{count}} mensaje revertido",
|
||||
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
|
||||
"session.revertDock.collapse": "Contraer mensajes revertidos",
|
||||
"session.revertDock.expand": "Expandir mensajes revertidos",
|
||||
"session.revertDock.restore": "Restaurar mensaje",
|
||||
|
||||
"session.new.title": "Construye lo que quieras",
|
||||
"session.new.worktree.main": "Rama principal",
|
||||
@@ -612,11 +634,20 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Cambiar el idioma de visualización para OpenCode",
|
||||
"settings.general.row.appearance.title": "Apariencia",
|
||||
"settings.general.row.appearance.description": "Personaliza cómo se ve OpenCode en tu dispositivo",
|
||||
"settings.general.row.colorScheme.title": "Esquema de color",
|
||||
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
|
||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||
"settings.general.row.followup.description":
|
||||
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
||||
"settings.general.row.followup.option.queue": "Cola",
|
||||
"settings.general.row.followup.option.steer": "Dirigir",
|
||||
"settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Mostrar resúmenes del razonamiento del modelo en la línea de tiempo",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Aucun résultat de modèle",
|
||||
"dialog.model.manage": "Gérer les modèles",
|
||||
"dialog.model.manage.description": "Personnalisez les modèles qui apparaissent dans le sélecteur.",
|
||||
"dialog.model.manage.provider.toggle": "Basculer tous les modèles {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "Modèles gratuits fournis par OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Ajouter plus de modèles de fournisseurs populaires",
|
||||
"dialog.provider.viewAll": "Voir plus de fournisseurs",
|
||||
@@ -288,6 +289,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Impossible de se connecter au serveur",
|
||||
"dialog.server.add.checking": "Vérification...",
|
||||
"dialog.server.add.button": "Ajouter un serveur",
|
||||
"dialog.server.add.name": "Nom du serveur (optionnel)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nom d'utilisateur (optionnel)",
|
||||
"dialog.server.add.password": "Mot de passe (optionnel)",
|
||||
"dialog.server.edit.title": "Modifier le serveur",
|
||||
"dialog.server.default.title": "Serveur par défaut",
|
||||
"dialog.server.default.description":
|
||||
"Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
|
||||
@@ -360,6 +366,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Langue",
|
||||
"toast.language.description": "Passé à {{language}}",
|
||||
"toast.theme.title": "Thème changé",
|
||||
@@ -451,8 +458,12 @@ export const dict = {
|
||||
"session.review.loadingChanges": "Chargement des modifications...",
|
||||
"session.review.empty": "Aucune modification dans cette session pour l'instant",
|
||||
"session.review.noChanges": "Aucune modification",
|
||||
"session.review.noVcs": "Aucun système de contrôle de version Git détecté, modifications non affichées",
|
||||
"session.review.noSnapshot":
|
||||
"Le suivi des instantanés est désactivé dans la configuration, les modifications de session sont donc indisponibles",
|
||||
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
|
||||
"session.files.all": "Tous les fichiers",
|
||||
"session.files.empty": "Aucun fichier",
|
||||
"session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
|
||||
"session.messages.renderEarlier": "Afficher les messages précédents",
|
||||
"session.messages.loadingEarlier": "Chargement des messages précédents...",
|
||||
@@ -463,6 +474,17 @@ export const dict = {
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.followupDock.summary.one": "{{count}} message en file d'attente",
|
||||
"session.followupDock.summary.other": "{{count}} messages en file d'attente",
|
||||
"session.followupDock.sendNow": "Envoyer maintenant",
|
||||
"session.followupDock.edit": "Modifier",
|
||||
"session.followupDock.collapse": "Réduire les messages en file d'attente",
|
||||
"session.followupDock.expand": "Développer les messages en file d'attente",
|
||||
"session.revertDock.summary.one": "{{count}} message annulé",
|
||||
"session.revertDock.summary.other": "{{count}} messages annulés",
|
||||
"session.revertDock.collapse": "Réduire les messages annulés",
|
||||
"session.revertDock.expand": "Développer les messages annulés",
|
||||
"session.revertDock.restore": "Restaurer le message",
|
||||
"session.new.title": "Créez ce que vous voulez",
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
@@ -550,10 +572,20 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Changer la langue d'affichage pour OpenCode",
|
||||
"settings.general.row.appearance.title": "Apparence",
|
||||
"settings.general.row.appearance.description": "Personnaliser l'apparence d'OpenCode sur votre appareil",
|
||||
"settings.general.row.colorScheme.title": "Schéma de couleurs",
|
||||
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
|
||||
"settings.general.row.theme.title": "Thème",
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.followup.title": "Comportement de suivi",
|
||||
"settings.general.row.followup.description":
|
||||
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
|
||||
"settings.general.row.followup.option.queue": "File d'attente",
|
||||
"settings.general.row.followup.option.steer": "Diriger",
|
||||
"settings.general.row.reasoningSummaries.title": "Afficher les résumés de raisonnement",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Afficher les résumés de raisonnement du modèle dans la chronologie",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const dict = {
|
||||
"dialog.model.empty": "モデルが見つかりません",
|
||||
"dialog.model.manage": "モデルを管理",
|
||||
"dialog.model.manage.description": "モデルセレクターに表示するモデルをカスタマイズします。",
|
||||
"dialog.model.manage.provider.toggle": "すべての{{provider}}モデルを切り替え",
|
||||
"dialog.model.unpaid.freeModels.title": "OpenCodeが提供する無料モデル",
|
||||
"dialog.model.unpaid.addMore.title": "人気のプロバイダーからモデルを追加",
|
||||
"dialog.provider.viewAll": "さらにプロバイダーを表示",
|
||||
@@ -287,6 +288,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "サーバーに接続できませんでした",
|
||||
"dialog.server.add.checking": "確認中...",
|
||||
"dialog.server.add.button": "サーバーを追加",
|
||||
"dialog.server.add.name": "サーバー名 (オプション)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ユーザー名 (オプション)",
|
||||
"dialog.server.add.password": "パスワード (オプション)",
|
||||
"dialog.server.edit.title": "サーバーを編集",
|
||||
"dialog.server.default.title": "デフォルトサーバー",
|
||||
"dialog.server.default.description":
|
||||
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
|
||||
@@ -358,6 +364,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "言語",
|
||||
"toast.language.description": "{{language}}に切り替えました",
|
||||
"toast.theme.title": "テーマが切り替わりました",
|
||||
@@ -444,9 +451,12 @@ export const dict = {
|
||||
"session.review.change.other": "変更",
|
||||
"session.review.loadingChanges": "変更を読み込み中...",
|
||||
"session.review.empty": "このセッションでの変更はまだありません",
|
||||
"session.review.noVcs": "Gitバージョン管理システムが検出されないため、変更は表示されません",
|
||||
"session.review.noSnapshot": "設定でスナップショット追跡が無効になっているため、セッションの変更は利用できません",
|
||||
"session.review.noChanges": "変更なし",
|
||||
"session.files.selectToOpen": "開くファイルを選択",
|
||||
"session.files.all": "すべてのファイル",
|
||||
"session.files.empty": "ファイルなし",
|
||||
"session.files.binaryContent": "バイナリファイル(内容を表示できません)",
|
||||
"session.messages.renderEarlier": "以前のメッセージを表示",
|
||||
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
|
||||
@@ -457,6 +467,17 @@ export const dict = {
|
||||
"session.todo.title": "ToDo",
|
||||
"session.todo.collapse": "折りたたむ",
|
||||
"session.todo.expand": "展開",
|
||||
"session.followupDock.summary.one": "{{count}} 件のメッセージが待機中",
|
||||
"session.followupDock.summary.other": "{{count}} 件のメッセージが待機中",
|
||||
"session.followupDock.sendNow": "今すぐ送信",
|
||||
"session.followupDock.edit": "編集",
|
||||
"session.followupDock.collapse": "待機中のメッセージを折りたたむ",
|
||||
"session.followupDock.expand": "待機中のメッセージを展開",
|
||||
"session.revertDock.summary.one": "{{count}} 件のロールバックされたメッセージ",
|
||||
"session.revertDock.summary.other": "{{count}} 件のロールバックされたメッセージ",
|
||||
"session.revertDock.collapse": "ロールバックされたメッセージを折りたたむ",
|
||||
"session.revertDock.expand": "ロールバックされたメッセージを展開",
|
||||
"session.revertDock.restore": "メッセージを復元",
|
||||
"session.new.title": "何でも作る",
|
||||
"session.new.worktree.main": "メインブランチ",
|
||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||
@@ -542,10 +563,19 @@ export const dict = {
|
||||
"settings.general.row.language.description": "OpenCodeの表示言語を変更します",
|
||||
"settings.general.row.appearance.title": "外観",
|
||||
"settings.general.row.appearance.description": "デバイスでのOpenCodeの表示をカスタマイズします",
|
||||
"settings.general.row.colorScheme.title": "配色",
|
||||
"settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します",
|
||||
"settings.general.row.theme.title": "テーマ",
|
||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.followup.title": "フォローアップの動作",
|
||||
"settings.general.row.followup.description":
|
||||
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
|
||||
"settings.general.row.followup.option.queue": "キューに追加",
|
||||
"settings.general.row.followup.option.steer": "即座に実行 (Steer)",
|
||||
"settings.general.row.reasoningSummaries.title": "推論の要約を表示",
|
||||
"settings.general.row.reasoningSummaries.description": "タイムラインにモデルの推論の要約を表示します",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
|
||||
|
||||
@@ -108,6 +108,7 @@ export const dict = {
|
||||
"dialog.model.empty": "모델 결과 없음",
|
||||
"dialog.model.manage": "모델 관리",
|
||||
"dialog.model.manage.description": "모델 선택기에 표시할 모델 사용자 지정",
|
||||
"dialog.model.manage.provider.toggle": "모든 {{provider}} 모델 토글",
|
||||
"dialog.model.unpaid.freeModels.title": "OpenCode에서 제공하는 무료 모델",
|
||||
"dialog.model.unpaid.addMore.title": "인기 공급자의 모델 추가",
|
||||
"dialog.provider.viewAll": "더 많은 공급자 보기",
|
||||
@@ -291,6 +292,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "서버에 연결할 수 없습니다",
|
||||
"dialog.server.add.checking": "확인 중...",
|
||||
"dialog.server.add.button": "서버 추가",
|
||||
"dialog.server.add.name": "서버 이름 (선택 사항)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "사용자 이름 (선택 사항)",
|
||||
"dialog.server.add.password": "비밀번호 (선택 사항)",
|
||||
"dialog.server.edit.title": "서버 편집",
|
||||
"dialog.server.default.title": "기본 서버",
|
||||
"dialog.server.default.description":
|
||||
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
|
||||
@@ -361,6 +367,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "언어",
|
||||
"toast.language.description": "{{language}}(으)로 전환됨",
|
||||
"toast.theme.title": "테마 전환됨",
|
||||
@@ -446,9 +453,12 @@ export const dict = {
|
||||
"session.review.change.other": "변경",
|
||||
"session.review.loadingChanges": "변경 사항 로드 중...",
|
||||
"session.review.empty": "이 세션에 변경 사항이 아직 없습니다",
|
||||
"session.review.noVcs": "Git 버전 관리 시스템이 감지되지 않아 변경 사항이 표시되지 않습니다",
|
||||
"session.review.noSnapshot": "구성에서 스냅샷 추적이 비활성화되어 있어 세션 변경 사항을 사용할 수 없습니다",
|
||||
"session.review.noChanges": "변경 없음",
|
||||
"session.files.selectToOpen": "열 파일을 선택하세요",
|
||||
"session.files.all": "모든 파일",
|
||||
"session.files.empty": "파일 없음",
|
||||
"session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
|
||||
"session.messages.renderEarlier": "이전 메시지 렌더링",
|
||||
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
|
||||
@@ -459,6 +469,17 @@ export const dict = {
|
||||
"session.todo.title": "할 일",
|
||||
"session.todo.collapse": "접기",
|
||||
"session.todo.expand": "펼치기",
|
||||
"session.followupDock.summary.one": "{{count}}개의 대기 중인 메시지",
|
||||
"session.followupDock.summary.other": "{{count}}개의 대기 중인 메시지",
|
||||
"session.followupDock.sendNow": "지금 전송",
|
||||
"session.followupDock.edit": "편집",
|
||||
"session.followupDock.collapse": "대기 중인 메시지 접기",
|
||||
"session.followupDock.expand": "대기 중인 메시지 펼치기",
|
||||
"session.revertDock.summary.one": "{{count}}개의 롤백된 메시지",
|
||||
"session.revertDock.summary.other": "{{count}}개의 롤백된 메시지",
|
||||
"session.revertDock.collapse": "롤백된 메시지 접기",
|
||||
"session.revertDock.expand": "롤백된 메시지 펼치기",
|
||||
"session.revertDock.restore": "메시지 복원",
|
||||
"session.new.title": "무엇이든 만들기",
|
||||
"session.new.worktree.main": "메인 브랜치",
|
||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||
@@ -543,10 +564,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "OpenCode 표시 언어 변경",
|
||||
"settings.general.row.appearance.title": "모양",
|
||||
"settings.general.row.appearance.description": "기기에서 OpenCode가 보이는 방식 사용자 지정",
|
||||
"settings.general.row.colorScheme.title": "색상 테마",
|
||||
"settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요",
|
||||
"settings.general.row.theme.title": "테마",
|
||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.followup.title": "후속 조치 동작",
|
||||
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
|
||||
"settings.general.row.followup.option.queue": "대기열",
|
||||
"settings.general.row.followup.option.steer": "조종",
|
||||
"settings.general.row.reasoningSummaries.title": "추론 요약 표시",
|
||||
"settings.general.row.reasoningSummaries.description": "타임라인에 모델 추론 요약 표시",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
|
||||
|
||||
@@ -116,6 +116,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Ingen modellresultater",
|
||||
"dialog.model.manage": "Administrer modeller",
|
||||
"dialog.model.manage.description": "Tilpass hvilke modeller som vises i modellvelgeren.",
|
||||
"dialog.model.manage.provider.toggle": "Veksle alle {{provider}}-modeller",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Gratis modeller levert av OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Legg til flere modeller fra populære leverandører",
|
||||
@@ -216,7 +217,7 @@ export const dict = {
|
||||
|
||||
"common.search.placeholder": "Søk",
|
||||
"common.goBack": "Gå tilbake",
|
||||
"common.goForward": "Navigate forward",
|
||||
"common.goForward": "Gå frem",
|
||||
"common.loading": "Laster",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Avbryt",
|
||||
@@ -317,6 +318,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Kunne ikke koble til server",
|
||||
"dialog.server.add.checking": "Sjekker...",
|
||||
"dialog.server.add.button": "Legg til server",
|
||||
"dialog.server.add.name": "Servernavn (valgfritt)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Brukernavn (valgfritt)",
|
||||
"dialog.server.add.password": "Passord (valgfritt)",
|
||||
"dialog.server.edit.title": "Rediger server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
|
||||
@@ -394,6 +400,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Språk",
|
||||
"toast.language.description": "Byttet til {{language}}",
|
||||
@@ -499,10 +506,14 @@ export const dict = {
|
||||
"session.review.change.other": "Endringer",
|
||||
"session.review.loadingChanges": "Laster endringer...",
|
||||
"session.review.empty": "Ingen endringer i denne sesjonen ennå",
|
||||
"session.review.noVcs": "Ingen Git-versjonskontrollsystem oppdaget, endringer vises ikke",
|
||||
"session.review.noSnapshot":
|
||||
"Snapshot-sporing er deaktivert i konfigurasjonen, så sesjonsendringer er ikke tilgjengelige",
|
||||
"session.review.noChanges": "Ingen endringer",
|
||||
|
||||
"session.files.selectToOpen": "Velg en fil å åpne",
|
||||
"session.files.all": "Alle filer",
|
||||
"session.files.empty": "Ingen filer",
|
||||
"session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
|
||||
|
||||
"session.messages.renderEarlier": "Vis tidligere meldinger",
|
||||
@@ -515,6 +526,17 @@ export const dict = {
|
||||
"session.todo.title": "Oppgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Utvid",
|
||||
"session.followupDock.summary.one": "{{count}} melding i kø",
|
||||
"session.followupDock.summary.other": "{{count}} meldinger i kø",
|
||||
"session.followupDock.sendNow": "Send nå",
|
||||
"session.followupDock.edit": "Rediger",
|
||||
"session.followupDock.collapse": "Skjul meldinger i kø",
|
||||
"session.followupDock.expand": "Utvid meldinger i kø",
|
||||
"session.revertDock.summary.one": "{{count}} tilbakestilt melding",
|
||||
"session.revertDock.summary.other": "{{count}} tilbakestilte meldinger",
|
||||
"session.revertDock.collapse": "Skjul tilbakestilte meldinger",
|
||||
"session.revertDock.expand": "Utvid tilbakestilte meldinger",
|
||||
"session.revertDock.restore": "Gjenopprett melding",
|
||||
|
||||
"session.new.title": "Bygg hva som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
@@ -612,11 +634,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Endre visningsspråket for OpenCode",
|
||||
"settings.general.row.appearance.title": "Utseende",
|
||||
"settings.general.row.appearance.description": "Tilpass hvordan OpenCode ser ut på enheten din",
|
||||
"settings.general.row.colorScheme.title": "Fargevalg",
|
||||
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
||||
"settings.general.row.font.title": "Skrift",
|
||||
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||
|
||||
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
||||
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
"settings.general.row.followup.option.steer": "Styr",
|
||||
"settings.general.row.reasoningSummaries.title": "Vis resonneringssammendrag",
|
||||
"settings.general.row.reasoningSummaries.description": "Vis sammendrag av modellresonnering i tidslinjen",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Brak wyników modelu",
|
||||
"dialog.model.manage": "Zarządzaj modelami",
|
||||
"dialog.model.manage.description": "Dostosuj, które modele pojawiają się w wyborze modelu.",
|
||||
"dialog.model.manage.provider.toggle": "Przełącz wszystkie modele {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "Darmowe modele dostarczane przez OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Dodaj więcej modeli od popularnych dostawców",
|
||||
"dialog.provider.viewAll": "Zobacz więcej dostawców",
|
||||
@@ -289,6 +290,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Nie można połączyć się z serwerem",
|
||||
"dialog.server.add.checking": "Sprawdzanie...",
|
||||
"dialog.server.add.button": "Dodaj serwer",
|
||||
"dialog.server.add.name": "Nazwa serwera (opcjonalnie)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nazwa użytkownika (opcjonalnie)",
|
||||
"dialog.server.add.password": "Hasło (opcjonalnie)",
|
||||
"dialog.server.edit.title": "Edytuj serwer",
|
||||
"dialog.server.default.title": "Domyślny serwer",
|
||||
"dialog.server.default.description":
|
||||
"Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
|
||||
@@ -359,6 +365,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
"toast.language.title": "Język",
|
||||
"toast.language.description": "Przełączono na {{language}}",
|
||||
"toast.theme.title": "Przełączono motyw",
|
||||
@@ -445,9 +452,12 @@ export const dict = {
|
||||
"session.review.change.other": "Zmiany",
|
||||
"session.review.loadingChanges": "Ładowanie zmian...",
|
||||
"session.review.empty": "Brak zmian w tej sesji",
|
||||
"session.review.noVcs": "Nie wykryto systemu kontroli wersji Git, zmiany nie są wyświetlane",
|
||||
"session.review.noSnapshot": "Śledzenie migawek jest wyłączone w konfiguracji, więc zmiany w sesji są niedostępne",
|
||||
"session.review.noChanges": "Brak zmian",
|
||||
"session.files.selectToOpen": "Wybierz plik do otwarcia",
|
||||
"session.files.all": "Wszystkie pliki",
|
||||
"session.files.empty": "Brak plików",
|
||||
"session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
|
||||
"session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
|
||||
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
|
||||
@@ -458,6 +468,17 @@ export const dict = {
|
||||
"session.todo.title": "Zadania",
|
||||
"session.todo.collapse": "Zwiń",
|
||||
"session.todo.expand": "Rozwiń",
|
||||
"session.followupDock.summary.one": "{{count}} wiadomość w kolejce",
|
||||
"session.followupDock.summary.other": "{{count}} wiadomości w kolejce",
|
||||
"session.followupDock.sendNow": "Wyślij teraz",
|
||||
"session.followupDock.edit": "Edytuj",
|
||||
"session.followupDock.collapse": "Zwiń wiadomości w kolejce",
|
||||
"session.followupDock.expand": "Rozwiń wiadomości w kolejce",
|
||||
"session.revertDock.summary.one": "{{count}} cofnięta wiadomość",
|
||||
"session.revertDock.summary.other": "{{count}} cofnięte wiadomości",
|
||||
"session.revertDock.collapse": "Zwiń cofnięte wiadomości",
|
||||
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
|
||||
"session.revertDock.restore": "Przywróć wiadomość",
|
||||
"session.new.title": "Zbuduj cokolwiek",
|
||||
"session.new.worktree.main": "Główna gałąź",
|
||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||
@@ -543,10 +564,19 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Zmień język wyświetlania dla OpenCode",
|
||||
"settings.general.row.appearance.title": "Wygląd",
|
||||
"settings.general.row.appearance.description": "Dostosuj wygląd OpenCode na swoim urządzeniu",
|
||||
"settings.general.row.colorScheme.title": "Schemat kolorów",
|
||||
"settings.general.row.colorScheme.description":
|
||||
"Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego",
|
||||
"settings.general.row.theme.title": "Motyw",
|
||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.followup.title": "Zachowanie kontynuacji",
|
||||
"settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce",
|
||||
"settings.general.row.followup.option.queue": "Kolejka",
|
||||
"settings.general.row.followup.option.steer": "Sterowanie",
|
||||
"settings.general.row.reasoningSummaries.title": "Pokaż podsumowania wnioskowania",
|
||||
"settings.general.row.reasoningSummaries.description": "Wyświetlaj podsumowania wnioskowania modelu na osi czasu",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
|
||||
|
||||
@@ -113,6 +113,7 @@ export const dict = {
|
||||
"dialog.model.empty": "Модели не найдены",
|
||||
"dialog.model.manage": "Управление моделями",
|
||||
"dialog.model.manage.description": "Настройте какие модели появляются в выборе модели",
|
||||
"dialog.model.manage.provider.toggle": "Переключить все модели {{provider}}",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Бесплатные модели от OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Добавьте больше моделей от популярных провайдеров",
|
||||
@@ -314,6 +315,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Не удалось подключиться к серверу",
|
||||
"dialog.server.add.checking": "Проверка...",
|
||||
"dialog.server.add.button": "Добавить сервер",
|
||||
"dialog.server.add.name": "Имя сервера (необязательно)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Имя пользователя (необязательно)",
|
||||
"dialog.server.add.password": "Пароль (необязательно)",
|
||||
"dialog.server.edit.title": "Редактировать сервер",
|
||||
"dialog.server.default.title": "Сервер по умолчанию",
|
||||
"dialog.server.default.description":
|
||||
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
|
||||
@@ -393,6 +399,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Язык",
|
||||
"toast.language.description": "Переключено на {{language}}",
|
||||
@@ -499,9 +506,12 @@ export const dict = {
|
||||
"session.review.change.other": "Изменения",
|
||||
"session.review.loadingChanges": "Загрузка изменений...",
|
||||
"session.review.empty": "Изменений в этой сессии пока нет",
|
||||
"session.review.noVcs": "Система контроля версий Git не обнаружена, изменения не отображаются",
|
||||
"session.review.noSnapshot": "Отслеживание снимков отключено в настройках, поэтому изменения сессии недоступны",
|
||||
"session.review.noChanges": "Нет изменений",
|
||||
"session.files.selectToOpen": "Выберите файл, чтобы открыть",
|
||||
"session.files.all": "Все файлы",
|
||||
"session.files.empty": "Нет файлов",
|
||||
"session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
|
||||
"session.messages.renderEarlier": "Показать предыдущие сообщения",
|
||||
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
|
||||
@@ -513,6 +523,17 @@ export const dict = {
|
||||
"session.todo.title": "Задачи",
|
||||
"session.todo.collapse": "Свернуть",
|
||||
"session.todo.expand": "Развернуть",
|
||||
"session.followupDock.summary.one": "{{count}} сообщение в очереди",
|
||||
"session.followupDock.summary.other": "{{count}} сообщений в очереди",
|
||||
"session.followupDock.sendNow": "Отправить сейчас",
|
||||
"session.followupDock.edit": "Редактировать",
|
||||
"session.followupDock.collapse": "Свернуть сообщения в очереди",
|
||||
"session.followupDock.expand": "Развернуть сообщения в очереди",
|
||||
"session.revertDock.summary.one": "{{count}} сообщение возвращено",
|
||||
"session.revertDock.summary.other": "{{count}} сообщений возвращено",
|
||||
"session.revertDock.collapse": "Свернуть возвращённые сообщения",
|
||||
"session.revertDock.expand": "Развернуть возвращённые сообщения",
|
||||
"session.revertDock.restore": "Восстановить сообщение",
|
||||
|
||||
"session.new.title": "Создавайте что угодно",
|
||||
"session.new.worktree.main": "Основная ветка",
|
||||
@@ -610,10 +631,19 @@ export const dict = {
|
||||
"settings.general.row.language.description": "Изменить язык отображения OpenCode",
|
||||
"settings.general.row.appearance.title": "Внешний вид",
|
||||
"settings.general.row.appearance.description": "Настройте как OpenCode выглядит на вашем устройстве",
|
||||
"settings.general.row.colorScheme.title": "Цветовая схема",
|
||||
"settings.general.row.colorScheme.description": "Выберите, следует ли OpenCode системной, светлой или тёмной теме",
|
||||
"settings.general.row.theme.title": "Тема",
|
||||
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
|
||||
"settings.general.row.font.title": "Шрифт",
|
||||
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
|
||||
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
|
||||
"settings.general.row.followup.description":
|
||||
"Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
|
||||
"settings.general.row.followup.option.queue": "Очередь",
|
||||
"settings.general.row.followup.option.steer": "Направлять",
|
||||
"settings.general.row.reasoningSummaries.title": "Показывать сводки рассуждений",
|
||||
"settings.general.row.reasoningSummaries.description": "Отображать сводки рассуждений модели в ленте",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
@@ -767,30 +797,31 @@ export const dict = {
|
||||
"settings.permissions.tool.glob.description": "Сопоставление файлов по паттернам glob",
|
||||
"settings.permissions.tool.grep.title": "Grep",
|
||||
"settings.permissions.tool.grep.description": "Поиск по содержимому файлов с использованием регулярных выражений",
|
||||
"settings.permissions.tool.list.title": "Список",
|
||||
"settings.permissions.tool.list.title": "List",
|
||||
"settings.permissions.tool.list.description": "Список файлов в директории",
|
||||
"settings.permissions.tool.bash.title": "Bash",
|
||||
"settings.permissions.tool.bash.description": "Выполнение команд оболочки",
|
||||
"settings.permissions.tool.bash.description": "Запуск команд оболочки",
|
||||
"settings.permissions.tool.task.title": "Task",
|
||||
"settings.permissions.tool.task.description": "Запуск под-агентов",
|
||||
"settings.permissions.tool.task.description": "Запуск подагентов",
|
||||
"settings.permissions.tool.skill.title": "Skill",
|
||||
"settings.permissions.tool.skill.description": "Загрузить навык по имени",
|
||||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Выполнение запросов к языковому серверу",
|
||||
"settings.permissions.tool.todoread.title": "Чтение списка задач",
|
||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Чтение списка задач",
|
||||
"settings.permissions.tool.todowrite.title": "Запись списка задач",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Получить содержимое по URL",
|
||||
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
"settings.permissions.tool.websearch.description": "Поиск в интернете",
|
||||
"settings.permissions.tool.codesearch.title": "Поиск кода",
|
||||
"settings.permissions.tool.codesearch.title": "Code Search",
|
||||
"settings.permissions.tool.codesearch.description": "Поиск кода в интернете",
|
||||
"settings.permissions.tool.external_directory.title": "Внешняя директория",
|
||||
"settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта",
|
||||
"settings.permissions.tool.doom_loop.title": "Doom Loop",
|
||||
"settings.permissions.tool.doom_loop.description": "Обнаружение повторных вызовов инструментов с одинаковым вводом",
|
||||
"settings.permissions.tool.doom_loop.description":
|
||||
"Обнаружение повторяющихся вызовов инструментов с одинаковыми входными данными",
|
||||
|
||||
"session.delete.failed.title": "Не удалось удалить сессию",
|
||||
"session.delete.title": "Удалить сессию",
|
||||
@@ -807,21 +838,21 @@ export const dict = {
|
||||
"workspace.reset.failed.title": "Не удалось сбросить рабочее пространство",
|
||||
"workspace.reset.success.title": "Рабочее пространство сброшено",
|
||||
"workspace.reset.success.description": "Рабочее пространство теперь соответствует ветке по умолчанию.",
|
||||
"workspace.error.stillPreparing": "Рабочее пространство всё ещё готовится",
|
||||
"workspace.status.checking": "Проверка наличия неслитых изменений...",
|
||||
"workspace.error.stillPreparing": "Рабочее пространство всё ещё подготавливается",
|
||||
"workspace.status.checking": "Проверка незафиксированных изменений...",
|
||||
"workspace.status.error": "Не удалось проверить статус git.",
|
||||
"workspace.status.clean": "Неслитые изменения не обнаружены.",
|
||||
"workspace.status.dirty": "Обнаружены неслитые изменения в этом рабочем пространстве.",
|
||||
"workspace.status.clean": "Незафиксированных изменений не обнаружено.",
|
||||
"workspace.status.dirty": "В этом рабочем пространстве обнаружены незафиксированные изменения.",
|
||||
"workspace.delete.title": "Удалить рабочее пространство",
|
||||
"workspace.delete.confirm": 'Удалить рабочее пространство "{{name}}"?',
|
||||
"workspace.delete.button": "Удалить рабочее пространство",
|
||||
"workspace.reset.title": "Сбросить рабочее пространство",
|
||||
"workspace.reset.confirm": 'Сбросить рабочее пространство "{{name}}"?',
|
||||
"workspace.reset.button": "Сбросить рабочее пространство",
|
||||
"workspace.reset.archived.none": "Никакие активные сессии не будут архивированы.",
|
||||
"workspace.reset.archived.none": "Активные сессии не будут архивированы.",
|
||||
"workspace.reset.archived.one": "1 сессия будет архивирована.",
|
||||
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
|
||||
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
|
||||
"workspace.reset.note": "Это сбросит рабочее пространство до соответствия ветке по умолчанию.",
|
||||
"common.open": "Открыть",
|
||||
"dialog.releaseNotes.action.getStarted": "Начать",
|
||||
"dialog.releaseNotes.action.next": "Далее",
|
||||
|
||||
@@ -113,6 +113,7 @@ export const dict = {
|
||||
"dialog.model.empty": "ไม่พบผลลัพธ์โมเดล",
|
||||
"dialog.model.manage": "จัดการโมเดล",
|
||||
"dialog.model.manage.description": "ปรับแต่งโมเดลที่จะปรากฏในตัวเลือกโมเดล",
|
||||
"dialog.model.manage.provider.toggle": "สลับโมเดลทั้งหมดของ {{provider}}",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "โมเดลฟรีที่จัดหาให้โดย OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "เพิ่มโมเดลเพิ่มเติมจากผู้ให้บริการยอดนิยม",
|
||||
@@ -314,6 +315,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์",
|
||||
"dialog.server.add.checking": "กำลังตรวจสอบ...",
|
||||
"dialog.server.add.button": "เพิ่มเซิร์ฟเวอร์",
|
||||
"dialog.server.add.name": "ชื่อเซิร์ฟเวอร์ (ไม่บังคับ)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ชื่อผู้ใช้ (ไม่บังคับ)",
|
||||
"dialog.server.add.password": "รหัสผ่าน (ไม่บังคับ)",
|
||||
"dialog.server.edit.title": "แก้ไขเซิร์ฟเวอร์",
|
||||
"dialog.server.default.title": "เซิร์ฟเวอร์เริ่มต้น",
|
||||
"dialog.server.default.description":
|
||||
"เชื่อมต่อกับเซิร์ฟเวอร์นี้เมื่อเปิดแอปแทนการเริ่มเซิร์ฟเวอร์ในเครื่อง ต้องรีสตาร์ท",
|
||||
@@ -391,6 +397,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "ภาษา",
|
||||
"toast.language.description": "สลับไปที่ {{language}}",
|
||||
@@ -494,9 +501,12 @@ export const dict = {
|
||||
"session.review.change.other": "การเปลี่ยนแปลง",
|
||||
"session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...",
|
||||
"session.review.empty": "ยังไม่มีการเปลี่ยนแปลงในเซสชันนี้",
|
||||
"session.review.noVcs": "ไม่ตรวจพบระบบควบคุมเวอร์ชัน Git การเปลี่ยนแปลงจะไม่แสดง",
|
||||
"session.review.noSnapshot": "การติดตามสแนปชอตถูกปิดใช้งานในการกำหนดค่า ดังนั้นการเปลี่ยนแปลงเซสชันจึงไม่พร้อมใช้งาน",
|
||||
"session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
|
||||
|
||||
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
|
||||
"session.files.empty": "ไม่มีไฟล์",
|
||||
"session.files.all": "ไฟล์ทั้งหมด",
|
||||
"session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
|
||||
|
||||
@@ -510,6 +520,17 @@ export const dict = {
|
||||
"session.todo.title": "สิ่งที่ต้องทำ",
|
||||
"session.todo.collapse": "ย่อ",
|
||||
"session.todo.expand": "ขยาย",
|
||||
"session.followupDock.summary.one": "{{count}} ข้อความในคิว",
|
||||
"session.followupDock.summary.other": "{{count}} ข้อความในคิว",
|
||||
"session.followupDock.sendNow": "ส่งทันที",
|
||||
"session.followupDock.edit": "แก้ไข",
|
||||
"session.followupDock.collapse": "ย่อข้อความในคิว",
|
||||
"session.followupDock.expand": "ขยายข้อความในคิว",
|
||||
"session.revertDock.summary.one": "{{count}} ข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.summary.other": "{{count}} ข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.collapse": "ย่อข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.expand": "ขยายข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.restore": "กู้คืนข้อความ",
|
||||
|
||||
"session.new.title": "สร้างอะไรก็ได้",
|
||||
"session.new.worktree.main": "สาขาหลัก",
|
||||
@@ -604,11 +625,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ OpenCode",
|
||||
"settings.general.row.appearance.title": "รูปลักษณ์",
|
||||
"settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ OpenCode มีลักษณะบนอุปกรณ์ของคุณ",
|
||||
"settings.general.row.colorScheme.title": "โทนสี",
|
||||
"settings.general.row.colorScheme.description": "เลือกว่าจะให้ OpenCode ใช้ธีมตามระบบ สว่าง หรือมืด",
|
||||
"settings.general.row.theme.title": "ธีม",
|
||||
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
|
||||
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
|
||||
"settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว",
|
||||
"settings.general.row.followup.option.queue": "คิว",
|
||||
"settings.general.row.followup.option.steer": "นำทาง",
|
||||
"settings.general.row.reasoningSummaries.title": "แสดงสรุปการใช้เหตุผล",
|
||||
"settings.general.row.reasoningSummaries.description": "แสดงสรุปการใช้เหตุผลของโมเดลในไทม์ไลน์",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||
|
||||
@@ -320,6 +320,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "Sunucuya bağlanılamadı",
|
||||
"dialog.server.add.checking": "Kontrol ediliyor...",
|
||||
"dialog.server.add.button": "Sunucu ekle",
|
||||
"dialog.server.add.name": "Sunucu adı (isteğe bağlı)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Kullanıcı adı (isteğe bağlı)",
|
||||
"dialog.server.add.password": "Şifre (isteğe bağlı)",
|
||||
"dialog.server.edit.title": "Sunucuyu düzenle",
|
||||
"dialog.server.default.title": "Varsayılan sunucu",
|
||||
"dialog.server.default.description":
|
||||
"Uygulama başlatıldığında yerel sunucu başlatmak yerine bu sunucuya bağlan. Yeniden başlatma gerektirir.",
|
||||
@@ -506,10 +511,13 @@ export const dict = {
|
||||
"session.review.loadingChanges": "Değişiklikler yükleniyor...",
|
||||
"session.review.empty": "Bu oturumda henüz değişiklik yok",
|
||||
"session.review.noVcs": "Git VCS algılanamadı, oturum değişiklikleri tespit edilemeyecek",
|
||||
"session.review.noSnapshot":
|
||||
"Yapılandırmada anlık görüntü takibi devre dışı bırakıldı, bu nedenle oturum değişiklikleri kullanılamıyor",
|
||||
"session.review.noChanges": "Değişiklik yok",
|
||||
|
||||
"session.files.selectToOpen": "Açmak için bir dosya seçin",
|
||||
"session.files.all": "Tüm dosyalar",
|
||||
"session.files.empty": "Dosya yok",
|
||||
"session.files.binaryContent": "İkili dosya (içerik görüntülenemiyor)",
|
||||
|
||||
"session.messages.renderEarlier": "Önceki mesajları göster",
|
||||
@@ -522,6 +530,17 @@ export const dict = {
|
||||
"session.todo.title": "Görevler",
|
||||
"session.todo.collapse": "Daralt",
|
||||
"session.todo.expand": "Genişlet",
|
||||
"session.followupDock.summary.one": "{{count}} sıradaki mesaj",
|
||||
"session.followupDock.summary.other": "{{count}} sıradaki mesaj",
|
||||
"session.followupDock.sendNow": "Şimdi gönder",
|
||||
"session.followupDock.edit": "Düzenle",
|
||||
"session.followupDock.collapse": "Sıradaki mesajları daralt",
|
||||
"session.followupDock.expand": "Sıradaki mesajları genişlet",
|
||||
"session.revertDock.summary.one": "{{count}} geri alınan mesaj",
|
||||
"session.revertDock.summary.other": "{{count}} geri alınan mesaj",
|
||||
"session.revertDock.collapse": "Geri alınan mesajları daralt",
|
||||
"session.revertDock.expand": "Geri alınan mesajları genişlet",
|
||||
"session.revertDock.restore": "Mesajı geri yükle",
|
||||
|
||||
"session.new.title": "İstediğini yap",
|
||||
"session.new.worktree.main": "Ana dal",
|
||||
@@ -618,10 +637,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "OpenCode'un görünüm dilini değiştirin",
|
||||
"settings.general.row.appearance.title": "Görünüm",
|
||||
"settings.general.row.appearance.description": "OpenCode'un cihazınızdaki görünümünü özelleştirin",
|
||||
"settings.general.row.colorScheme.title": "Renk şeması",
|
||||
"settings.general.row.colorScheme.description":
|
||||
"OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
|
||||
"settings.general.row.font.title": "Yazı Tipi",
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.followup.title": "Takip davranışı",
|
||||
"settings.general.row.followup.description":
|
||||
"Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
|
||||
"settings.general.row.followup.option.queue": "Sıra",
|
||||
"settings.general.row.followup.option.steer": "Yönlendir",
|
||||
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
|
||||
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
|
||||
|
||||
@@ -140,6 +140,7 @@ export const dict = {
|
||||
"dialog.model.empty": "未找到模型",
|
||||
"dialog.model.manage": "管理模型",
|
||||
"dialog.model.manage.description": "自定义模型选择器中显示的模型。",
|
||||
"dialog.model.manage.provider.toggle": "切换所有 {{provider}} 模型",
|
||||
"dialog.model.unpaid.freeModels.title": "OpenCode 提供的免费模型",
|
||||
"dialog.model.unpaid.addMore.title": "从热门提供商添加更多模型",
|
||||
|
||||
@@ -334,6 +335,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "无法连接到服务器",
|
||||
"dialog.server.add.checking": "检查中...",
|
||||
"dialog.server.add.button": "添加服务器",
|
||||
"dialog.server.add.name": "服务器名称(可选)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "用户名(可选)",
|
||||
"dialog.server.add.password": "密码(可选)",
|
||||
"dialog.server.edit.title": "编辑服务器",
|
||||
"dialog.server.default.title": "默认服务器",
|
||||
"dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
|
||||
"dialog.server.default.none": "未选择服务器",
|
||||
@@ -406,6 +412,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "语言",
|
||||
"toast.language.description": "已切换到{{language}}",
|
||||
@@ -497,9 +504,12 @@ export const dict = {
|
||||
"session.review.change.other": "更改",
|
||||
"session.review.loadingChanges": "正在加载更改...",
|
||||
"session.review.empty": "此会话暂无更改",
|
||||
"session.review.noVcs": "未检测到 Git 版本控制系统,无法显示更改",
|
||||
"session.review.noSnapshot": "配置中已禁用快照跟踪,因此会话更改不可用",
|
||||
"session.review.noChanges": "无更改",
|
||||
"session.files.selectToOpen": "选择要打开的文件",
|
||||
"session.files.all": "所有文件",
|
||||
"session.files.empty": "无文件",
|
||||
"session.files.binaryContent": "二进制文件(无法显示内容)",
|
||||
"session.messages.renderEarlier": "显示更早的消息",
|
||||
"session.messages.loadingEarlier": "正在加载更早的消息...",
|
||||
@@ -510,6 +520,17 @@ export const dict = {
|
||||
"session.todo.title": "待办事项",
|
||||
"session.todo.collapse": "折叠",
|
||||
"session.todo.expand": "展开",
|
||||
"session.followupDock.summary.one": "{{count}} 条排队消息",
|
||||
"session.followupDock.summary.other": "{{count}} 条排队消息",
|
||||
"session.followupDock.sendNow": "立即发送",
|
||||
"session.followupDock.edit": "编辑",
|
||||
"session.followupDock.collapse": "折叠排队消息",
|
||||
"session.followupDock.expand": "展开排队消息",
|
||||
"session.revertDock.summary.one": "{{count}} 条已回滚消息",
|
||||
"session.revertDock.summary.other": "{{count}} 条已回滚消息",
|
||||
"session.revertDock.collapse": "折叠已回滚消息",
|
||||
"session.revertDock.expand": "展开已回滚消息",
|
||||
"session.revertDock.restore": "恢复消息",
|
||||
"session.new.title": "构建任何东西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||
@@ -604,10 +625,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "更改 OpenCode 的显示语言",
|
||||
"settings.general.row.appearance.title": "外观",
|
||||
"settings.general.row.appearance.description": "自定义 OpenCode 在你的设备上的外观",
|
||||
"settings.general.row.colorScheme.title": "配色方案",
|
||||
"settings.general.row.colorScheme.description": "选择 OpenCode 跟随系统、浅色或深色主题",
|
||||
"settings.general.row.theme.title": "主题",
|
||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.followup.title": "跟进消息行为",
|
||||
"settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待",
|
||||
"settings.general.row.followup.option.queue": "排队",
|
||||
"settings.general.row.followup.option.steer": "引导",
|
||||
"settings.general.row.reasoningSummaries.title": "显示推理摘要",
|
||||
"settings.general.row.reasoningSummaries.description": "在时间线中显示模型推理摘要",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
|
||||
@@ -117,6 +117,7 @@ export const dict = {
|
||||
"dialog.model.empty": "找不到模型",
|
||||
"dialog.model.manage": "管理模型",
|
||||
"dialog.model.manage.description": "自訂模型選擇器中顯示的模型。",
|
||||
"dialog.model.manage.provider.toggle": "切換所有 {{provider}} 模型",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "OpenCode 提供的免費模型",
|
||||
"dialog.model.unpaid.addMore.title": "從熱門提供者新增更多模型",
|
||||
@@ -314,6 +315,11 @@ export const dict = {
|
||||
"dialog.server.add.error": "無法連線到伺服器",
|
||||
"dialog.server.add.checking": "檢查中...",
|
||||
"dialog.server.add.button": "新增伺服器",
|
||||
"dialog.server.add.name": "伺服器名稱(選填)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "使用者名稱(選填)",
|
||||
"dialog.server.add.password": "密碼(選填)",
|
||||
"dialog.server.edit.title": "編輯伺服器",
|
||||
"dialog.server.default.title": "預設伺服器",
|
||||
"dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
|
||||
"dialog.server.default.none": "未選擇伺服器",
|
||||
@@ -390,6 +396,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "語言",
|
||||
"toast.language.description": "已切換到 {{language}}",
|
||||
@@ -493,8 +500,11 @@ export const dict = {
|
||||
"session.review.loadingChanges": "正在載入變更...",
|
||||
"session.review.empty": "此工作階段暫無變更",
|
||||
"session.review.noChanges": "沒有變更",
|
||||
"session.review.noVcs": "未偵測到 Git 版本控制系統,無法顯示變更",
|
||||
"session.review.noSnapshot": "設定中已停用快照追蹤,因此無法使用工作階段變更",
|
||||
"session.files.selectToOpen": "選取要開啟的檔案",
|
||||
"session.files.all": "所有檔案",
|
||||
"session.files.empty": "沒有檔案",
|
||||
"session.files.binaryContent": "二進位檔案(無法顯示內容)",
|
||||
"session.messages.renderEarlier": "顯示更早的訊息",
|
||||
"session.messages.loadingEarlier": "正在載入更早的訊息...",
|
||||
@@ -506,6 +516,17 @@ export const dict = {
|
||||
"session.todo.title": "待辦事項",
|
||||
"session.todo.collapse": "折疊",
|
||||
"session.todo.expand": "展開",
|
||||
"session.followupDock.summary.one": "{{count}} 則佇列訊息",
|
||||
"session.followupDock.summary.other": "{{count}} 則佇列訊息",
|
||||
"session.followupDock.sendNow": "立即傳送",
|
||||
"session.followupDock.edit": "編輯",
|
||||
"session.followupDock.collapse": "收合佇列訊息",
|
||||
"session.followupDock.expand": "展開佇列訊息",
|
||||
"session.revertDock.summary.one": "{{count}} 則已回復訊息",
|
||||
"session.revertDock.summary.other": "{{count}} 則已回復訊息",
|
||||
"session.revertDock.collapse": "收合已回復訊息",
|
||||
"session.revertDock.expand": "展開已回復訊息",
|
||||
"session.revertDock.restore": "還原訊息",
|
||||
|
||||
"session.new.title": "建構任何東西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
@@ -585,8 +606,8 @@ export const dict = {
|
||||
"settings.tab.general": "一般",
|
||||
"settings.tab.shortcuts": "快速鍵",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"settings.desktop.wsl.title": "WSL 整合",
|
||||
"settings.desktop.wsl.description": "在 Windows 上的 WSL 中執行 OpenCode 伺服器。",
|
||||
|
||||
"settings.general.section.appearance": "外觀",
|
||||
"settings.general.section.notifications": "系統通知",
|
||||
@@ -599,10 +620,18 @@ export const dict = {
|
||||
"settings.general.row.language.description": "變更 OpenCode 的顯示語言",
|
||||
"settings.general.row.appearance.title": "外觀",
|
||||
"settings.general.row.appearance.description": "自訂 OpenCode 在你的裝置上的外觀",
|
||||
"settings.general.row.colorScheme.title": "配色方案",
|
||||
"settings.general.row.colorScheme.description": "選擇 OpenCode 要跟隨系統、淺色或深色主題",
|
||||
"settings.general.row.theme.title": "主題",
|
||||
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
"settings.general.row.followup.title": "後續追問行為",
|
||||
"settings.general.row.followup.description": "選擇後續追問提示是立即引導還是進入佇列等待",
|
||||
"settings.general.row.followup.option.queue": "佇列",
|
||||
"settings.general.row.followup.option.steer": "引導",
|
||||
"settings.general.row.reasoningSummaries.title": "顯示推理摘要",
|
||||
"settings.general.row.reasoningSummaries.description": "在時間軸中顯示模型推理摘要",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
|
||||
@@ -35,6 +35,15 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import {
|
||||
clearSessionPrefetchInflight,
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
isSessionPrefetchCurrent,
|
||||
runSessionPrefetch,
|
||||
SESSION_PREFETCH_TTL,
|
||||
setSessionPrefetch,
|
||||
} from "@/context/global-sync/session-prefetch"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
@@ -51,7 +60,7 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
@@ -62,7 +71,6 @@ import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
getDraggableId,
|
||||
latestRootSession,
|
||||
sortedRootSessions,
|
||||
workspaceKey,
|
||||
@@ -80,7 +88,6 @@ import {
|
||||
WorkspaceDragOverlay,
|
||||
type WorkspaceSidebarContext,
|
||||
} from "./layout/sidebar-workspace"
|
||||
import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
@@ -664,8 +671,9 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const prefetchChunk = 200
|
||||
const prefetchConcurrency = 1
|
||||
const prefetchPendingLimit = 6
|
||||
const prefetchConcurrency = 2
|
||||
const prefetchPendingLimit = 10
|
||||
const span = 4
|
||||
const prefetchToken = { value: 0 }
|
||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||
|
||||
@@ -690,14 +698,30 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const active = new Set(visibleSessionDirs())
|
||||
for (const directory of [...prefetchedByDir.keys()]) {
|
||||
if (active.has(directory)) continue
|
||||
prefetchedByDir.delete(directory)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
params.dir
|
||||
globalSDK.url
|
||||
|
||||
prefetchToken.value += 1
|
||||
for (const q of prefetchQueues.values()) {
|
||||
clearSessionPrefetchInflight()
|
||||
prefetchQueues.clear()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const visible = new Set(visibleSessionDirs())
|
||||
for (const [directory, q] of prefetchQueues) {
|
||||
if (visible.has(directory)) continue
|
||||
q.pending.length = 0
|
||||
q.pendingSet.clear()
|
||||
if (q.running === 0) prefetchQueues.delete(directory)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -733,36 +757,67 @@ export default function Layout(props: ParentProps) {
|
||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
|
||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!lruFor(directory).has(sessionID)) return
|
||||
return runSessionPrefetch({
|
||||
directory,
|
||||
sessionID,
|
||||
task: (rev) =>
|
||||
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
const sorted = mergeByID([], next)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
const sorted = mergeByID([], next)
|
||||
const stale = markPrefetched(directory, sessionID)
|
||||
const meta = {
|
||||
limit: prefetchChunk,
|
||||
complete: sorted.length < prefetchChunk,
|
||||
at: Date.now(),
|
||||
}
|
||||
|
||||
const current = store.message[sessionID] ?? []
|
||||
const merged = mergeByID(
|
||||
current.filter((item): item is Message => !!item?.id),
|
||||
sorted,
|
||||
)
|
||||
if (stale.length > 0) {
|
||||
clearSessionPrefetch(directory, stale)
|
||||
for (const id of stale) {
|
||||
globalSync.todo.set(id, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||
|
||||
for (const message of items) {
|
||||
const currentParts = store.part[message.info.id] ?? []
|
||||
const mergedParts = mergeByID(
|
||||
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
|
||||
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
|
||||
const current = store.message[sessionID] ?? []
|
||||
const merged = mergeByID(
|
||||
current.filter((item): item is Message => !!item?.id),
|
||||
sorted,
|
||||
)
|
||||
|
||||
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||
|
||||
batch(() => {
|
||||
if (stale.length > 0) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||
setSessionPrefetch({ directory, sessionID, ...meta })
|
||||
|
||||
for (const message of items) {
|
||||
const currentParts = store.part[message.info.id] ?? []
|
||||
const mergedParts = mergeByID(
|
||||
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
|
||||
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
|
||||
)
|
||||
|
||||
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
||||
}
|
||||
})
|
||||
|
||||
return meta
|
||||
})
|
||||
.catch(() => undefined),
|
||||
})
|
||||
}
|
||||
|
||||
const pumpPrefetch = (directory: string) => {
|
||||
@@ -790,28 +845,29 @@ export default function Layout(props: ParentProps) {
|
||||
if (!directory) return
|
||||
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const cached = untrack(() => store.message[session.id] !== undefined)
|
||||
const cached = untrack(() => {
|
||||
if (store.message[session.id] === undefined) return false
|
||||
const info = getSessionPrefetch(directory, session.id)
|
||||
if (!info) return false
|
||||
return Date.now() - info.at < SESSION_PREFETCH_TTL
|
||||
})
|
||||
if (cached) return
|
||||
|
||||
const q = queueFor(directory)
|
||||
if (q.inflight.has(session.id)) return
|
||||
if (q.pendingSet.has(session.id)) return
|
||||
if (q.pendingSet.has(session.id)) {
|
||||
if (priority !== "high") return
|
||||
const index = q.pending.indexOf(session.id)
|
||||
if (index > 0) {
|
||||
q.pending.splice(index, 1)
|
||||
q.pending.unshift(session.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const lru = lruFor(directory)
|
||||
const known = lru.has(session.id)
|
||||
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
||||
const stale = markPrefetched(directory, session.id)
|
||||
if (stale.length > 0) {
|
||||
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
for (const id of stale) {
|
||||
globalSync.todo.set(id, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (priority === "high") q.pending.unshift(session.id)
|
||||
if (priority !== "high") q.pending.push(session.id)
|
||||
@@ -826,27 +882,29 @@ export default function Layout(props: ParentProps) {
|
||||
pumpPrefetch(directory)
|
||||
}
|
||||
|
||||
const warm = (sessions: Session[], index: number) => {
|
||||
for (let offset = 1; offset <= span; offset++) {
|
||||
const next = sessions[index + offset]
|
||||
if (next) prefetchSession(next, offset === 1 ? "high" : "low")
|
||||
|
||||
const prev = sessions[index - offset]
|
||||
if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const sessions = currentSessions()
|
||||
const id = params.id
|
||||
if (sessions.length === 0) return
|
||||
|
||||
if (!id) {
|
||||
const first = sessions[0]
|
||||
if (first) prefetchSession(first)
|
||||
|
||||
const second = sessions[1]
|
||||
if (second) prefetchSession(second)
|
||||
return
|
||||
}
|
||||
|
||||
const index = sessions.findIndex((s) => s.id === id)
|
||||
const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
|
||||
if (index === -1) return
|
||||
|
||||
const next = sessions[index + 1]
|
||||
if (next) prefetchSession(next)
|
||||
if (!params.id) {
|
||||
const first = sessions[index]
|
||||
if (first) prefetchSession(first, "high")
|
||||
}
|
||||
|
||||
const prev = sessions[index - 1]
|
||||
if (prev) prefetchSession(prev)
|
||||
warm(sessions, index)
|
||||
})
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
@@ -865,18 +923,8 @@ export default function Layout(props: ParentProps) {
|
||||
const session = sessions[targetIndex]
|
||||
if (!session) return
|
||||
|
||||
const next = sessions[(targetIndex + 1) % sessions.length]
|
||||
const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
|
||||
|
||||
if (offset > 0) {
|
||||
if (next) prefetchSession(next, "high")
|
||||
if (prev) prefetchSession(prev)
|
||||
}
|
||||
|
||||
if (offset < 0) {
|
||||
if (prev) prefetchSession(prev, "high")
|
||||
if (next) prefetchSession(next)
|
||||
}
|
||||
prefetchSession(session, "high")
|
||||
warm(sessions, targetIndex)
|
||||
|
||||
navigateToSession(session)
|
||||
}
|
||||
@@ -898,19 +946,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (notification.session.unseenCount(session.id) === 0) continue
|
||||
|
||||
prefetchSession(session, "high")
|
||||
|
||||
const next = sessions[(index + 1) % sessions.length]
|
||||
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
|
||||
|
||||
if (offset > 0) {
|
||||
if (next) prefetchSession(next, "high")
|
||||
if (prev) prefetchSession(prev)
|
||||
}
|
||||
|
||||
if (offset < 0) {
|
||||
if (prev) prefetchSession(prev, "high")
|
||||
if (next) prefetchSession(next)
|
||||
}
|
||||
warm(sessions, index)
|
||||
|
||||
navigateToSession(session)
|
||||
return
|
||||
@@ -1844,6 +1880,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const workspaceSidebarCtx: WorkspaceSidebarContext = {
|
||||
currentDir,
|
||||
navList: currentSessions,
|
||||
sidebarExpanded,
|
||||
sidebarHovering,
|
||||
nav: () => state.nav,
|
||||
@@ -1860,7 +1897,7 @@ export default function Layout(props: ParentProps) {
|
||||
setEditor,
|
||||
InlineEditor,
|
||||
isBusy,
|
||||
workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
|
||||
workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
|
||||
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
|
||||
showResetWorkspaceDialog: (root, directory) =>
|
||||
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
|
||||
@@ -1889,6 +1926,7 @@ export default function Layout(props: ParentProps) {
|
||||
workspaceIds,
|
||||
workspaceLabel,
|
||||
sessionProps: {
|
||||
navList: currentSessions,
|
||||
sidebarExpanded,
|
||||
sidebarHovering,
|
||||
nav: () => state.nav,
|
||||
@@ -1904,6 +1942,7 @@ export default function Layout(props: ParentProps) {
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
||||
const projectName = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return ""
|
||||
@@ -2047,6 +2086,7 @@ export default function Layout(props: ParentProps) {
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
popover={popover()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -2082,6 +2122,7 @@ export default function Layout(props: ParentProps) {
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
popover={popover()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -2135,6 +2176,41 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const projects = () => layout.projects.list()
|
||||
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
|
||||
const sidebarContent = (mobile?: boolean) => (
|
||||
<SidebarContent
|
||||
mobile={mobile}
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={projects}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={projectOverlay}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() =>
|
||||
mobile ? (
|
||||
<SidebarPanel project={currentProject()} mobile />
|
||||
) : (
|
||||
<Show when={currentProject()}>
|
||||
<SidebarPanel project={currentProject()} merged />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar />
|
||||
@@ -2163,38 +2239,7 @@ export default function Layout(props: ParentProps) {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
<SidebarContent
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay
|
||||
projects={() => layout.projects.list()}
|
||||
activeProject={() => store.activeProject}
|
||||
/>
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
@@ -2241,33 +2286,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent
|
||||
mobile
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay
|
||||
projects={() => layout.projects.list()}
|
||||
activeProject={() => store.activeProject}
|
||||
/>
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
|
||||
/>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -2313,8 +2332,8 @@ export default function Layout(props: ParentProps) {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
<Show when={peek()}>
|
||||
<SidebarPanel project={peek()} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { hasProjectPermissions, latestRootSession } from "./helpers"
|
||||
import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
hasProjectPermissions,
|
||||
latestRootSession,
|
||||
workspaceKey,
|
||||
} from "./helpers"
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
@@ -110,7 +116,7 @@ describe("layout workspace helpers", () => {
|
||||
})
|
||||
|
||||
test("keeps local first while preserving known order", () => {
|
||||
const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
||||
const result = effectiveWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
||||
expect(result).toEqual(["/root", "/c", "/b"])
|
||||
})
|
||||
|
||||
@@ -192,12 +198,6 @@ describe("layout workspace helpers", () => {
|
||||
expect(result?.id).toBe("root")
|
||||
})
|
||||
|
||||
test("extracts draggable id safely", () => {
|
||||
expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
|
||||
expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
|
||||
expect(getDraggableId(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("formats fallback project display name", () => {
|
||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||
|
||||
@@ -8,7 +8,7 @@ export const workspaceKey = (directory: string) => {
|
||||
return directory.replace(/[\\/]+$/, "")
|
||||
}
|
||||
|
||||
export function sortSessions(now: number) {
|
||||
function sortSessions(now: number) {
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
return (a: Session, b: Session) => {
|
||||
const aUpdated = a.time.updated ?? a.time.created
|
||||
@@ -22,7 +22,7 @@ export function sortSessions(now: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
||||
|
||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
||||
@@ -54,14 +54,6 @@ export const childMapByParent = (sessions: Session[]) => {
|
||||
return map
|
||||
}
|
||||
|
||||
export function getDraggableId(event: unknown): string | undefined {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree)
|
||||
|
||||
@@ -98,5 +90,3 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
|
||||
|
||||
return [...result, ...live.values()]
|
||||
}
|
||||
|
||||
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
||||
|
||||
@@ -67,6 +67,8 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
||||
|
||||
export type SessionItemProps = {
|
||||
session: Session
|
||||
list: Session[]
|
||||
navList?: Accessor<Session[]>
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
@@ -95,18 +97,18 @@ const SessionRow = (props: {
|
||||
setHoverSession: (id: string | undefined) => void
|
||||
clearHoverProjectSoon: () => void
|
||||
sidebarOpened: Accessor<boolean>
|
||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||
scheduleHoverPrefetch: () => void
|
||||
warmHover: () => void
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
cancelHoverPrefetch: () => void
|
||||
}): JSX.Element => (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerEnter={props.scheduleHoverPrefetch}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
onMouseEnter={props.scheduleHoverPrefetch}
|
||||
onMouseLeave={props.cancelHoverPrefetch}
|
||||
onFocus={() => props.prefetchSession(props.session, "high")}
|
||||
onFocus={props.warmFocus}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (props.sidebarOpened()) return
|
||||
@@ -225,11 +227,31 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const hoverMessages = createMemo(() =>
|
||||
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
||||
)
|
||||
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
||||
const hoverReady = createMemo(() => hoverMessages() !== undefined)
|
||||
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||
const isActive = createMemo(() => props.session.id === params.id)
|
||||
|
||||
const warm = (span: number, priority: "high" | "low") => {
|
||||
const nav = props.navList?.()
|
||||
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||
? nav
|
||||
: props.list
|
||||
|
||||
props.prefetchSession(props.session, priority)
|
||||
|
||||
const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||
if (idx === -1) return
|
||||
|
||||
for (let step = 1; step <= span; step++) {
|
||||
const next = list[idx + step]
|
||||
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
|
||||
|
||||
const prev = list[idx - step]
|
||||
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
|
||||
}
|
||||
}
|
||||
|
||||
const hoverPrefetch = {
|
||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
}
|
||||
@@ -239,11 +261,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
hoverPrefetch.current = undefined
|
||||
}
|
||||
const scheduleHoverPrefetch = () => {
|
||||
warm(1, "high")
|
||||
if (hoverPrefetch.current !== undefined) return
|
||||
hoverPrefetch.current = setTimeout(() => {
|
||||
hoverPrefetch.current = undefined
|
||||
props.prefetchSession(props.session)
|
||||
}, 200)
|
||||
warm(2, "low")
|
||||
}, 80)
|
||||
}
|
||||
|
||||
onCleanup(cancelHoverPrefetch)
|
||||
@@ -267,8 +290,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
setHoverSession={props.setHoverSession}
|
||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||
sidebarOpened={layout.sidebar.opened}
|
||||
prefetchSession={props.prefetchSession}
|
||||
scheduleHoverPrefetch={scheduleHoverPrefetch}
|
||||
warmHover={scheduleHoverPrefetch}
|
||||
warmPress={() => warm(2, "high")}
|
||||
warmFocus={() => warm(2, "high")}
|
||||
cancelHoverPrefetch={cancelHoverPrefetch}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
|
||||
|
||||
describe("projectSelected", () => {
|
||||
test("matches direct worktree", () => {
|
||||
expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches sandbox worktree", () => {
|
||||
expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true)
|
||||
expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("projectTileActive", () => {
|
||||
test("menu state always wins", () => {
|
||||
expect(
|
||||
projectTileActive({
|
||||
menu: true,
|
||||
preview: false,
|
||||
open: false,
|
||||
overlay: false,
|
||||
worktree: "/tmp/root",
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("preview mode uses open state", () => {
|
||||
expect(
|
||||
projectTileActive({
|
||||
menu: false,
|
||||
preview: true,
|
||||
open: true,
|
||||
overlay: true,
|
||||
hoverProject: "/tmp/other",
|
||||
worktree: "/tmp/root",
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("overlay mode uses hovered project", () => {
|
||||
expect(
|
||||
projectTileActive({
|
||||
menu: false,
|
||||
preview: false,
|
||||
open: false,
|
||||
overlay: true,
|
||||
hoverProject: "/tmp/root",
|
||||
worktree: "/tmp/root",
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
projectTileActive({
|
||||
menu: false,
|
||||
preview: false,
|
||||
open: false,
|
||||
overlay: true,
|
||||
hoverProject: "/tmp/other",
|
||||
worktree: "/tmp/root",
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) =>
|
||||
worktree === currentDir || sandboxes?.includes(currentDir) === true
|
||||
|
||||
export const projectTileActive = (args: {
|
||||
menu: boolean
|
||||
preview: boolean
|
||||
open: boolean
|
||||
overlay: boolean
|
||||
hoverProject?: string
|
||||
worktree: string
|
||||
}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree)
|
||||
@@ -12,7 +12,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
|
||||
|
||||
export type ProjectSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
@@ -31,7 +30,7 @@ export type ProjectSidebarContext = {
|
||||
workspacesEnabled: (project: LocalProject) => boolean
|
||||
workspaceIds: (project: LocalProject) => string[]
|
||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||
sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
|
||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
|
||||
setHoverSession: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
@@ -205,11 +204,12 @@ const ProjectPreviewPanel = (props: {
|
||||
<Show
|
||||
when={props.workspaceEnabled()}
|
||||
fallback={
|
||||
<For each={props.projectSessions()}>
|
||||
<For each={props.projectSessions().slice(0, 2)}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
{...props.ctx.sessionProps}
|
||||
session={session}
|
||||
list={props.projectSessions()}
|
||||
slug={base64Encode(props.project.worktree)}
|
||||
dense
|
||||
mobile={props.mobile}
|
||||
@@ -232,11 +232,12 @@ const ProjectPreviewPanel = (props: {
|
||||
</div>
|
||||
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
||||
</div>
|
||||
<For each={sessions()}>
|
||||
<For each={sessions().slice(0, 2)}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
{...props.ctx.sessionProps}
|
||||
session={session}
|
||||
list={sessions()}
|
||||
slug={base64Encode(directory)}
|
||||
dense
|
||||
mobile={props.mobile}
|
||||
@@ -277,8 +278,10 @@ export const SortableProject = (props: {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const selected = createMemo(() =>
|
||||
projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
|
||||
const selected = createMemo(
|
||||
() =>
|
||||
props.project.worktree === props.ctx.currentDir() ||
|
||||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
|
||||
)
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
@@ -291,15 +294,8 @@ export const SortableProject = (props: {
|
||||
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(() =>
|
||||
projectTileActive({
|
||||
menu: state.menu,
|
||||
preview: preview(),
|
||||
open: state.open,
|
||||
overlay: overlay(),
|
||||
hoverProject: props.ctx.hoverProject(),
|
||||
worktree: props.project.worktree,
|
||||
}),
|
||||
const active = createMemo(
|
||||
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -323,11 +319,11 @@ export const SortableProject = (props: {
|
||||
}
|
||||
|
||||
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2))
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
return sortedRootSessions(data, props.sortNow()).slice(0, 2)
|
||||
return sortedRootSessions(data, props.sortNow())
|
||||
}
|
||||
const workspaceChildren = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sidebarExpanded } from "./sidebar-shell-helpers"
|
||||
|
||||
describe("sidebarExpanded", () => {
|
||||
test("expands on mobile regardless of desktop open state", () => {
|
||||
expect(sidebarExpanded(true, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("follows desktop open state when not mobile", () => {
|
||||
expect(sidebarExpanded(false, true)).toBe(true)
|
||||
expect(sidebarExpanded(false, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { sidebarExpanded } from "./sidebar-shell-helpers"
|
||||
|
||||
export const SidebarContent = (props: {
|
||||
mobile?: boolean
|
||||
@@ -33,7 +32,7 @@ export const SidebarContent = (props: {
|
||||
onOpenHelp: () => void
|
||||
renderPanel: () => JSX.Element
|
||||
}): JSX.Element => {
|
||||
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
||||
const expanded = createMemo(() => !!props.mobile || props.opened())
|
||||
const placement = () => (props.mobile ? "bottom" : "right")
|
||||
let panel: HTMLDivElement | undefined
|
||||
|
||||
@@ -116,7 +115,7 @@ export const SidebarContent = (props: {
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
|
||||
expanded[directory] ?? local
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { workspaceOpenState } from "./sidebar-workspace-helpers"
|
||||
|
||||
describe("workspaceOpenState", () => {
|
||||
test("defaults to local workspace open", () => {
|
||||
expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true)
|
||||
})
|
||||
|
||||
test("uses persisted expansion state when present", () => {
|
||||
expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false)
|
||||
expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ type InlineEditorComponent = (props: {
|
||||
|
||||
export type WorkspaceSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
navList: Accessor<Session[]>
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
nav: Accessor<HTMLElement | undefined>
|
||||
@@ -144,8 +145,6 @@ const WorkspaceActions = (props: {
|
||||
setMenuOpen: (open: boolean) => void
|
||||
setPendingRename: (value: boolean) => void
|
||||
sidebarHovering: Accessor<boolean>
|
||||
mobile?: boolean
|
||||
nav: Accessor<HTMLElement | undefined>
|
||||
touch: Accessor<boolean>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
workspaceValue: Accessor<string>
|
||||
@@ -240,6 +239,7 @@ const WorkspaceActions = (props: {
|
||||
const WorkspaceSessionList = (props: {
|
||||
slug: Accessor<string>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
ctx: WorkspaceSidebarContext
|
||||
showNew: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
@@ -266,8 +266,11 @@ const WorkspaceSessionList = (props: {
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
session={session}
|
||||
list={props.sessions()}
|
||||
navList={props.ctx.navList}
|
||||
slug={props.slug()}
|
||||
mobile={props.mobile}
|
||||
popover={props.popover}
|
||||
children={props.children()}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
@@ -304,6 +307,7 @@ export const SortableWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
@@ -340,6 +344,22 @@ export const SortableWorkspace = (props: {
|
||||
}
|
||||
|
||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||
const header = () => (
|
||||
<WorkspaceHeader
|
||||
local={local}
|
||||
busy={busy}
|
||||
open={open}
|
||||
directory={props.directory}
|
||||
language={language}
|
||||
branch={() => workspaceStore.vcs?.branch}
|
||||
workspaceValue={workspaceValue}
|
||||
workspaceEditActive={workspaceEditActive}
|
||||
InlineEditor={props.ctx.InlineEditor}
|
||||
renameWorkspace={props.ctx.renameWorkspace}
|
||||
setEditor={props.ctx.setEditor}
|
||||
projectId={props.project.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const openWrapper = (value: boolean) => {
|
||||
props.ctx.setWorkspaceExpanded(props.directory, value)
|
||||
@@ -379,20 +399,7 @@ export const SortableWorkspace = (props: {
|
||||
data-action="workspace-toggle"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
<WorkspaceHeader
|
||||
local={local}
|
||||
busy={busy}
|
||||
open={open}
|
||||
directory={props.directory}
|
||||
language={language}
|
||||
branch={() => workspaceStore.vcs?.branch}
|
||||
workspaceValue={workspaceValue}
|
||||
workspaceEditActive={workspaceEditActive}
|
||||
InlineEditor={props.ctx.InlineEditor}
|
||||
renameWorkspace={props.ctx.renameWorkspace}
|
||||
setEditor={props.ctx.setEditor}
|
||||
projectId={props.project.id}
|
||||
/>
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
@@ -401,20 +408,7 @@ export const SortableWorkspace = (props: {
|
||||
menu.open ? "pr-16" : "pr-2"
|
||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||
>
|
||||
<WorkspaceHeader
|
||||
local={local}
|
||||
busy={busy}
|
||||
open={open}
|
||||
directory={props.directory}
|
||||
language={language}
|
||||
branch={() => workspaceStore.vcs?.branch}
|
||||
workspaceValue={workspaceValue}
|
||||
workspaceEditActive={workspaceEditActive}
|
||||
InlineEditor={props.ctx.InlineEditor}
|
||||
renameWorkspace={props.ctx.renameWorkspace}
|
||||
setEditor={props.ctx.setEditor}
|
||||
projectId={props.project.id}
|
||||
/>
|
||||
{header()}
|
||||
</div>
|
||||
</Show>
|
||||
<WorkspaceActions
|
||||
@@ -426,8 +420,6 @@ export const SortableWorkspace = (props: {
|
||||
setMenuOpen={(open) => setMenu("open", open)}
|
||||
setPendingRename={(value) => setMenu("pendingRename", value)}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
mobile={props.mobile}
|
||||
nav={props.ctx.nav}
|
||||
touch={touch}
|
||||
language={language}
|
||||
workspaceValue={workspaceValue}
|
||||
@@ -447,6 +439,7 @@ export const SortableWorkspace = (props: {
|
||||
<WorkspaceSessionList
|
||||
slug={slug}
|
||||
mobile={props.mobile}
|
||||
popover={props.popover}
|
||||
ctx={props.ctx}
|
||||
showNew={showNew}
|
||||
loading={loading}
|
||||
@@ -467,6 +460,7 @@ export const LocalWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
@@ -490,44 +484,19 @@ export const LocalWorkspace = (props: {
|
||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-3">
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
<For each={sessions()}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
session={session}
|
||||
slug={slug()}
|
||||
mobile={props.mobile}
|
||||
children={children()}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
nav={props.ctx.nav}
|
||||
hoverSession={props.ctx.hoverSession}
|
||||
setHoverSession={props.ctx.setHoverSession}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
prefetchSession={props.ctx.prefetchSession}
|
||||
archiveSession={props.ctx.archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={hasMore()}>
|
||||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
{language.t("common.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
<WorkspaceSessionList
|
||||
slug={slug}
|
||||
mobile={props.mobile}
|
||||
popover={props.popover}
|
||||
ctx={props.ctx}
|
||||
showNew={() => false}
|
||||
loading={loading}
|
||||
sessions={sessions}
|
||||
children={children}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,2 @@
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||
export type { SessionComposerState } from "./session-composer-state"
|
||||
export { createSessionComposerState } from "./session-composer-state"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -20,30 +23,29 @@ export function SessionComposerRegion(props: {
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
followup?: {
|
||||
queue: () => boolean
|
||||
items: { id: string; text: string }[]
|
||||
sending?: string
|
||||
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
|
||||
onQueue: (draft: FollowupDraft) => void
|
||||
onAbort: () => void
|
||||
onSend: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onEditLoaded: () => void
|
||||
}
|
||||
revert?: {
|
||||
items: { id: string; text: string }[]
|
||||
restoring?: string
|
||||
disabled?: boolean
|
||||
onRestore: (id: string) => void
|
||||
}
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
dockOpenVisualDuration?: number
|
||||
dockOpenBounce?: number
|
||||
dockCloseVisualDuration?: number
|
||||
dockCloseBounce?: number
|
||||
drawerExpandVisualDuration?: number
|
||||
drawerExpandBounce?: number
|
||||
drawerCollapseVisualDuration?: number
|
||||
drawerCollapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
const { sessionKey } = useSessionKey()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
||||
|
||||
const previewPrompt = () =>
|
||||
@@ -63,8 +65,10 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [gate, setGate] = createStore({
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
@@ -86,13 +90,13 @@ export function SessionComposerRegion(props: {
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setGate("ready", false)
|
||||
setStore("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setGate("ready", true)
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
@@ -100,30 +104,19 @@ export function SessionComposerRegion(props: {
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
@@ -170,9 +163,23 @@ export function SessionComposerRegion(props: {
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
<>
|
||||
<Show when={rolled()} keyed>
|
||||
{(revert) => (
|
||||
<div class="pb-2">
|
||||
<SessionRevertDock
|
||||
items={revert.items}
|
||||
restoring={revert.restoring}
|
||||
disabled={revert.disabled}
|
||||
onRestore={revert.onRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={dock()}>
|
||||
@@ -185,42 +192,58 @@ export function SessionComposerRegion(props: {
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef}>
|
||||
<div ref={(el) => setStore("body", el)}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={rolled()} keyed>
|
||||
{(revert) => (
|
||||
<div
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
}}
|
||||
>
|
||||
<SessionRevertDock
|
||||
items={revert.items}
|
||||
restoring={revert.restoring}
|
||||
disabled={revert.disabled}
|
||||
onRestore={revert.onRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
"margin-top": `${-lift()}px`,
|
||||
}}
|
||||
>
|
||||
<Show when={props.followup?.items.length}>
|
||||
<SessionFollowupDock
|
||||
items={props.followup!.items}
|
||||
sending={props.followup!.sending}
|
||||
onSend={props.followup!.onSend}
|
||||
onEdit={props.followup!.onEdit}
|
||||
/>
|
||||
</Show>
|
||||
<PromptInput
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
edit={props.followup?.edit}
|
||||
onEditLoaded={props.followup?.onEditLoaded}
|
||||
shouldQueue={props.followup?.queue}
|
||||
onQueue={props.followup?.onQueue}
|
||||
onAbort={props.followup?.onAbort}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
@@ -103,3 +104,25 @@ describe("sessionQuestionRequest", () => {
|
||||
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoState", () => {
|
||||
test("hides when there are no todos", () => {
|
||||
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
|
||||
})
|
||||
|
||||
test("opens while the session is still working", () => {
|
||||
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
|
||||
})
|
||||
|
||||
test("closes completed todos after a running turn", () => {
|
||||
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
|
||||
})
|
||||
|
||||
test("clears stale todos when the turn ends", () => {
|
||||
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
|
||||
})
|
||||
|
||||
test("clears completed todos when the session is no longer live", () => {
|
||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user