mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(desktop): progress
This commit is contained in:
parent
e9b95b2e91
commit
d66d806700
10 changed files with 483 additions and 424 deletions
|
|
@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff"
|
|||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { SessionProvider } from "@/context/session"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
|
|
@ -53,9 +54,11 @@ export function App() {
|
|||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id || true} keyed>
|
||||
<SessionProvider>
|
||||
<Session />
|
||||
</SessionProvider>
|
||||
<TerminalProvider>
|
||||
<PromptProvider>
|
||||
<Session />
|
||||
</PromptProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
|||
import { List } from "@opencode-ai/ui/list"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useSession } from "@/context/session"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
export function DialogSelectFile() {
|
||||
const session = useSession()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
return (
|
||||
<Dialog title="Select file">
|
||||
<List
|
||||
|
|
@ -20,7 +25,7 @@ export function DialogSelectFile() {
|
|||
key={(x) => x}
|
||||
onSelect={(path) => {
|
||||
if (path) {
|
||||
session.layout.openTab("file://" + path)
|
||||
tabs().open("file://" + path)
|
||||
}
|
||||
dialog.clear()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { createStore } from "solid-js/store"
|
|||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
|
||||
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
|
|
@ -67,12 +68,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
const session = useSession()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
// Session-derived state
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const status = createMemo(
|
||||
() =>
|
||||
sync.data.session_status[params.id ?? ""] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
popover: "file" | "slash" | null
|
||||
historyIndex: number
|
||||
|
|
@ -111,9 +126,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
|
||||
|
||||
const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
|
||||
const length = position === "start" ? 0 : promptLength(prompt)
|
||||
session.prompt.set(prompt, length)
|
||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||
const length = position === "start" ? 0 : promptLength(p)
|
||||
prompt.set(p, length)
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, length)
|
||||
|
|
@ -149,9 +164,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
createEffect(() => {
|
||||
session.id
|
||||
params.id
|
||||
editorRef.focus()
|
||||
if (session.id) return
|
||||
if (params.id) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
||||
}, 6500)
|
||||
|
|
@ -211,7 +226,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
if (!cmd) return
|
||||
// Since slash commands only trigger from start, just clear the input
|
||||
editorRef.innerHTML = ""
|
||||
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
setStore("popover", null)
|
||||
command.trigger(cmd.id, "slash")
|
||||
}
|
||||
|
|
@ -243,7 +258,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
createEffect(
|
||||
on(
|
||||
() => session.prompt.current(),
|
||||
() => prompt.current(),
|
||||
(currentParts) => {
|
||||
const domParts = parseFromDOM()
|
||||
if (isPromptEqual(currentParts, domParts)) return
|
||||
|
|
@ -255,7 +270,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
editorRef.innerHTML = ""
|
||||
currentParts.forEach((part) => {
|
||||
currentParts.forEach((part: ContentPart) => {
|
||||
if (part.type === "text") {
|
||||
editorRef.appendChild(document.createTextNode(part.content))
|
||||
} else if (part.type === "file") {
|
||||
|
|
@ -333,7 +348,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
setStore("savedPrompt", null)
|
||||
}
|
||||
|
||||
session.prompt.set(rawParts, cursorPosition)
|
||||
prompt.set(rawParts, cursorPosition)
|
||||
}
|
||||
|
||||
const addPart = (part: ContentPart) => {
|
||||
|
|
@ -341,8 +356,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
if (!selection || selection.rangeCount === 0) return
|
||||
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const prompt = session.prompt.current()
|
||||
const rawText = prompt.map((p) => p.content).join("")
|
||||
const currentPrompt = prompt.current()
|
||||
const rawText = currentPrompt.map((p: ContentPart) => p.content).join("")
|
||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||
|
||||
|
|
@ -403,7 +418,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
const abort = () =>
|
||||
sdk.client.session.abort({
|
||||
sessionID: session.id!,
|
||||
sessionID: params.id!,
|
||||
})
|
||||
|
||||
const addToHistory = (prompt: Prompt) => {
|
||||
|
|
@ -430,7 +445,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
if (direction === "up") {
|
||||
if (entries.length === 0) return false
|
||||
if (current === -1) {
|
||||
setStore("savedPrompt", clonePromptParts(session.prompt.current()))
|
||||
setStore("savedPrompt", clonePromptParts(prompt.current()))
|
||||
setStore("historyIndex", 0)
|
||||
applyHistoryPrompt(entries[0], "start")
|
||||
return true
|
||||
|
|
@ -481,7 +496,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
|
||||
if (!collapsed) return
|
||||
const cursorPos = getCursorPosition(editorRef)
|
||||
const textLength = promptLength(session.prompt.current())
|
||||
const textLength = promptLength(prompt.current())
|
||||
const inHistory = store.historyIndex >= 0
|
||||
const isStart = cursorPos === 0
|
||||
const isEnd = cursorPos === textLength
|
||||
|
|
@ -511,7 +526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
if (event.key === "Escape") {
|
||||
if (store.popover) {
|
||||
setStore("popover", null)
|
||||
} else if (session.working()) {
|
||||
} else if (working()) {
|
||||
abort()
|
||||
}
|
||||
}
|
||||
|
|
@ -519,18 +534,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
const prompt = session.prompt.current()
|
||||
const text = prompt.map((part) => part.content).join("")
|
||||
const currentPrompt = prompt.current()
|
||||
const text = currentPrompt.map((part: ContentPart) => part.content).join("")
|
||||
if (text.trim().length === 0) {
|
||||
if (session.working()) abort()
|
||||
if (working()) abort()
|
||||
return
|
||||
}
|
||||
|
||||
addToHistory(prompt)
|
||||
addToHistory(currentPrompt)
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
|
||||
let existing = session.info()
|
||||
let existing = info()
|
||||
if (!existing) {
|
||||
const created = await sdk.client.session.create()
|
||||
existing = created.data ?? undefined
|
||||
|
|
@ -539,7 +554,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
if (!existing) return
|
||||
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
const attachments = prompt.filter((part) => part.type === "file")
|
||||
const attachments = currentPrompt.filter(
|
||||
(part: ContentPart) => part.type === "file",
|
||||
) as import("@/context/prompt").FileAttachmentPart[]
|
||||
|
||||
const attachmentParts = attachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
|
|
@ -563,10 +580,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
})
|
||||
|
||||
session.layout.setActiveTab(undefined)
|
||||
session.messages.setActive(undefined)
|
||||
tabs().setActive(undefined)
|
||||
editorRef.innerHTML = ""
|
||||
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
|
||||
sdk.client.session.prompt({
|
||||
sessionID: existing.id,
|
||||
|
|
@ -671,7 +687,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||
}}
|
||||
/>
|
||||
<Show when={!session.prompt.dirty()}>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
|
||||
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
|
||||
</div>
|
||||
|
|
@ -703,7 +719,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
inactive={!session.prompt.dirty() && !session.working()}
|
||||
value={
|
||||
<Switch>
|
||||
<Match when={session.working()}>
|
||||
<Match when={working()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Stop</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
|
||||
|
|
@ -720,8 +736,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
>
|
||||
<IconButton
|
||||
type="submit"
|
||||
disabled={!session.prompt.dirty() && !session.working()}
|
||||
icon={session.working() ? "stop" : "arrow-up"}
|
||||
disabled={!prompt.dirty() && !working()}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-10 w-8 absolute right-2 bottom-2"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
|||
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/session"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { usePrefersDark } from "@solid-primitives/media"
|
||||
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
|
|||
search={{ placeholder: "Search commands", autofocus: true }}
|
||||
emptyMessage="No commands found"
|
||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||
key={(x) => x.id}
|
||||
key={(x) => x?.id}
|
||||
groupBy={(x) => x.category ?? ""}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createMemo, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
|
|
@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) {
|
|||
}
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
|
|
@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
review: {
|
||||
state: "pane" as "pane" | "tab",
|
||||
},
|
||||
sessionTabs: {} as Record<string, SessionTabs>,
|
||||
}),
|
||||
{
|
||||
name: "layout.v2",
|
||||
name: "layout.v3",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
setStore("review", "state", "tab")
|
||||
},
|
||||
},
|
||||
tabs(sessionKey: string) {
|
||||
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all),
|
||||
setActive(tab: string | undefined) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
}
|
||||
},
|
||||
setAll(all: string[]) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "all", all)
|
||||
}
|
||||
},
|
||||
async open(tab: string) {
|
||||
if (tab === "chat") {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "active", undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
||||
if (tab !== "review") {
|
||||
if (!current.all.includes(tab)) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
}
|
||||
},
|
||||
close(tab: string) {
|
||||
const current = store.sessionTabs[sessionKey]
|
||||
if (!current) return
|
||||
batch(() => {
|
||||
setStore(
|
||||
"sessionTabs",
|
||||
sessionKey,
|
||||
"all",
|
||||
current.all.filter((x) => x !== tab),
|
||||
)
|
||||
if (current.active === tab) {
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
const previous = current.all[Math.max(0, index - 1)]
|
||||
setStore("sessionTabs", sessionKey, "active", previous)
|
||||
}
|
||||
})
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const current = store.sessionTabs[sessionKey]
|
||||
if (!current) return
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionTabs",
|
||||
sessionKey,
|
||||
"all",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
100
packages/desktop/src/context/prompt.tsx
Normal file
100
packages/desktop/src/context/prompt.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { TextSelection } from "./local"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: TextSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
|
||||
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
}>({
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
}),
|
||||
{
|
||||
name: name(),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
current: createMemo(() => store.prompt),
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { TextSelection } from "./local"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
title: string
|
||||
rows?: number
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
}
|
||||
|
||||
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
|
||||
name: "Session",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`)
|
||||
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<{
|
||||
messageId?: string
|
||||
tabs: {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
terminals: {
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
}
|
||||
}>({
|
||||
tabs: {
|
||||
all: [],
|
||||
},
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
terminals: { all: [] },
|
||||
}),
|
||||
{
|
||||
name: name(),
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.id) return
|
||||
sync.session.sync(params.id)
|
||||
})
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(-1)
|
||||
})
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
return userMessages()?.find((m) => m.id === store.messageId)
|
||||
})
|
||||
const status = createMemo(
|
||||
() =>
|
||||
sync.data.session_status[params.id ?? ""] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const last = createMemo(
|
||||
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
|
||||
)
|
||||
const model = createMemo(() =>
|
||||
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
||||
)
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const tokens = last().tokens
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const total = tokens()
|
||||
const limit = model()?.limit.context
|
||||
if (!total || !limit) return 0
|
||||
return Math.round((total / limit) * 100)
|
||||
})
|
||||
|
||||
return {
|
||||
get id() {
|
||||
return params.id
|
||||
},
|
||||
info,
|
||||
status,
|
||||
working,
|
||||
diffs,
|
||||
prompt: {
|
||||
current: createMemo(() => store.prompt),
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
all: messages,
|
||||
user: userMessages,
|
||||
last: lastUserMessage,
|
||||
active: activeMessage,
|
||||
setActive(message: UserMessage | undefined) {
|
||||
setStore("messageId", message?.id)
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
tokens,
|
||||
cost,
|
||||
context,
|
||||
},
|
||||
layout: {
|
||||
tabs: store.tabs,
|
||||
setActiveTab(tab: string | undefined) {
|
||||
setStore("tabs", "active", tab)
|
||||
},
|
||||
setOpenedTabs(tabs: string[]) {
|
||||
setStore("tabs", "all", tabs)
|
||||
},
|
||||
async openTab(tab: string) {
|
||||
if (tab === "chat") {
|
||||
setStore("tabs", "active", undefined)
|
||||
return
|
||||
}
|
||||
if (tab !== "review") {
|
||||
if (!store.tabs.all.includes(tab)) {
|
||||
setStore("tabs", "all", [...store.tabs.all, tab])
|
||||
}
|
||||
}
|
||||
setStore("tabs", "active", tab)
|
||||
},
|
||||
closeTab(tab: string) {
|
||||
batch(() => {
|
||||
setStore(
|
||||
"tabs",
|
||||
"all",
|
||||
store.tabs.all.filter((x) => x !== tab),
|
||||
)
|
||||
if (store.tabs.active === tab) {
|
||||
const index = store.tabs.all.findIndex((f) => f === tab)
|
||||
const previous = store.tabs.all[Math.max(0, index - 1)]
|
||||
setStore("tabs", "active", previous)
|
||||
}
|
||||
})
|
||||
},
|
||||
moveTab(tab: string, to: number) {
|
||||
const index = store.tabs.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"tabs",
|
||||
"all",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
all: createMemo(() => Object.values(store.terminals.all)),
|
||||
active: createMemo(() => store.terminals.active),
|
||||
new() {
|
||||
sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
setStore("terminals", "all", [
|
||||
...store.terminals.all,
|
||||
{
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
},
|
||||
])
|
||||
setStore("terminals", "active", id)
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||
sdk.client.pty.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.terminals.all.findIndex((x) => x.id === id)
|
||||
const pty = store.terminals.all[index]
|
||||
if (!pty) return
|
||||
const clone = await sdk.client.pty.create({
|
||||
title: pty.title,
|
||||
})
|
||||
if (!clone.data) return
|
||||
setStore("terminals", "all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
})
|
||||
if (store.terminals.active === pty.id) {
|
||||
setStore("terminals", "active", clone.data.id)
|
||||
}
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("terminals", "active", id)
|
||||
},
|
||||
async close(id: string) {
|
||||
batch(() => {
|
||||
setStore(
|
||||
"terminals",
|
||||
"all",
|
||||
store.terminals.all.filter((x) => x.id !== id),
|
||||
)
|
||||
if (store.terminals.active === id) {
|
||||
const index = store.terminals.all.findIndex((f) => f.id === id)
|
||||
const previous = store.tabs.all[Math.max(0, index - 1)]
|
||||
setStore("terminals", "active", previous)
|
||||
}
|
||||
})
|
||||
await sdk.client.pty.remove({ ptyID: id })
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store.terminals.all.findIndex((f) => f.id === id)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"terminals",
|
||||
"all",
|
||||
produce((all) => {
|
||||
all.splice(to, 0, all.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: TextSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
106
packages/desktop/src/context/terminal.tsx
Normal file
106
packages/desktop/src/context/terminal.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
title: string
|
||||
rows?: number
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
}
|
||||
|
||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||
name: "Terminal",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const params = useParams()
|
||||
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
}>({
|
||||
all: [],
|
||||
}),
|
||||
{
|
||||
name: name(),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
active: createMemo(() => store.active),
|
||||
new() {
|
||||
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
setStore("all", [
|
||||
...store.all,
|
||||
{
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
},
|
||||
])
|
||||
setStore("active", id)
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||
sdk.client.pty.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const clone = await sdk.client.pty.create({
|
||||
title: pty.title,
|
||||
})
|
||||
if (!clone.data) return
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
})
|
||||
if (store.active === pty.id) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
},
|
||||
async close(id: string) {
|
||||
batch(() => {
|
||||
setStore(
|
||||
"all",
|
||||
store.all.filter((x) => x.id !== id),
|
||||
)
|
||||
if (store.active === id) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
const previous = store.all[Math.max(0, index - 1)]
|
||||
setStore("active", previous?.id)
|
||||
}
|
||||
})
|
||||
await sdk.client.pty.remove({ ptyID: id })
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"all",
|
||||
produce((all) => {
|
||||
all.splice(to, 0, all.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -27,22 +27,91 @@ import {
|
|||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSession, type LocalPTY } from "@/context/session"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const session = useSession()
|
||||
const terminal = useTerminal()
|
||||
const prompt = usePrompt()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const params = useParams()
|
||||
|
||||
// Session-specific derived state
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const lastUserMessage = createMemo(() => userMessages()?.at(-1))
|
||||
|
||||
const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!messageStore.messageId) return lastUserMessage()
|
||||
return userMessages()?.find((m) => m.id === messageStore.messageId)
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
setMessageStore("messageId", message?.id)
|
||||
}
|
||||
|
||||
const status = createMemo(
|
||||
() =>
|
||||
sync.data.session_status[params.id ?? ""] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const last = createMemo(
|
||||
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
|
||||
)
|
||||
const model = createMemo(() =>
|
||||
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
||||
)
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const t = last().tokens
|
||||
return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const total = tokens()
|
||||
const limit = model()?.limit.context
|
||||
if (!total || !limit) return 0
|
||||
return Math.round((total / limit) * 100)
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
activeDraggable: undefined as string | undefined,
|
||||
|
|
@ -50,10 +119,15 @@ export default function Page() {
|
|||
})
|
||||
let inputRef!: HTMLDivElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.id) return
|
||||
sync.session.sync(params.id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (layout.terminal.opened()) {
|
||||
if (session.terminal.all().length === 0) {
|
||||
session.terminal.new()
|
||||
if (terminal.all().length === 0) {
|
||||
terminal.new()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -99,7 +173,7 @@ export default function Page() {
|
|||
description: "Create a new terminal tab",
|
||||
category: "Terminal",
|
||||
keybind: "ctrl+shift+`",
|
||||
onSelect: () => session.terminal.new(),
|
||||
onSelect: () => terminal.new(),
|
||||
},
|
||||
])
|
||||
|
||||
|
|
@ -166,11 +240,11 @@ export default function Page() {
|
|||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentTabs = session.layout.tabs.all
|
||||
const currentTabs = tabs().all()
|
||||
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
|
||||
const toIndex = currentTabs?.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||
session.layout.moveTab(draggable.id.toString(), toIndex)
|
||||
tabs().move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -188,11 +262,11 @@ export default function Page() {
|
|||
const handleTerminalDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const terminals = session.terminal.all()
|
||||
const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
|
||||
const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
|
||||
const terminals = terminal.all()
|
||||
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
||||
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
session.terminal.move(draggable.id.toString(), toIndex)
|
||||
terminal.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -210,8 +284,8 @@ export default function Page() {
|
|||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
closeButton={
|
||||
session.terminal.all().length > 1 && (
|
||||
<IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} />
|
||||
terminal.all().length > 1 && (
|
||||
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
@ -326,7 +400,7 @@ export default function Page() {
|
|||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
|
||||
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
|
||||
const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
|
||||
|
|
@ -339,7 +413,7 @@ export default function Page() {
|
|||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
|
||||
<Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat">
|
||||
|
|
@ -349,15 +423,15 @@ export default function Page() {
|
|||
value={`${new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(session.usage.tokens() ?? 0)} Tokens`}
|
||||
}).format(tokens() ?? 0)} Tokens`}
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<ProgressCircle percentage={session.usage.context() ?? 0} />
|
||||
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
|
||||
<ProgressCircle percentage={context() ?? 0} />
|
||||
<div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
<Show when={layout.review.state() === "tab" && session.diffs().length}>
|
||||
<Show when={layout.review.state() === "tab" && diffs().length}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
closeButton={
|
||||
|
|
@ -367,25 +441,23 @@ export default function Page() {
|
|||
}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={session.diffs()}>
|
||||
<DiffChanges changes={session.diffs()} variant="bars" />
|
||||
<Show when={diffs()}>
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>Review</div>
|
||||
<Show when={session.info()?.summary?.files}>
|
||||
<Show when={info()?.summary?.files}>
|
||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||
{session.info()?.summary?.files ?? 0}
|
||||
{info()?.summary?.files ?? 0}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={session.layout.tabs.all ?? []}>
|
||||
<For each={session.layout.tabs.all ?? []}>
|
||||
{(tab) => (
|
||||
<SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
|
||||
)}
|
||||
<SortableProvider ids={tabs().all() ?? []}>
|
||||
<For each={tabs().all() ?? []}>
|
||||
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
|
|
@ -415,27 +487,23 @@ export default function Page() {
|
|||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={session.id}>
|
||||
<Match when={params.id}>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<SessionMessageRail
|
||||
messages={session.messages.user()}
|
||||
current={session.messages.active()}
|
||||
onMessageSelect={session.messages.setActive}
|
||||
messages={userMessages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
wide={wide()}
|
||||
/>
|
||||
<SessionTurn
|
||||
sessionID={session.id!}
|
||||
messageID={session.messages.active()?.id!}
|
||||
sessionID={params.id!}
|
||||
messageID={activeMessage()?.id!}
|
||||
classes={{
|
||||
root: "pb-20 flex-1 min-w-0",
|
||||
content: "pb-20",
|
||||
container:
|
||||
"w-full " +
|
||||
(wide()
|
||||
? "max-w-146 mx-auto px-6"
|
||||
: session.messages.user().length > 1
|
||||
? "pr-6 pl-18"
|
||||
: "px-6"),
|
||||
(wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -476,7 +544,7 @@ export default function Page() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={layout.review.state() === "pane" && session.diffs().length}>
|
||||
<Show when={layout.review.state() === "pane" && diffs().length}>
|
||||
<div
|
||||
classList={{
|
||||
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
|
||||
|
|
@ -488,7 +556,7 @@ export default function Page() {
|
|||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
diffs={session.diffs()}
|
||||
diffs={diffs()}
|
||||
actions={
|
||||
<Tooltip value="Open in tab">
|
||||
<IconButton
|
||||
|
|
@ -496,7 +564,7 @@ export default function Page() {
|
|||
variant="ghost"
|
||||
onClick={() => {
|
||||
layout.review.tab()
|
||||
session.layout.setActiveTab("review")
|
||||
tabs().setActive("review")
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
@ -506,7 +574,7 @@ export default function Page() {
|
|||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Show when={layout.review.state() === "tab" && session.diffs().length}>
|
||||
<Show when={layout.review.state() === "tab" && diffs().length}>
|
||||
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
|
||||
<div
|
||||
classList={{
|
||||
|
|
@ -519,13 +587,13 @@ export default function Page() {
|
|||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
diffs={session.diffs()}
|
||||
diffs={diffs()}
|
||||
split
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<For each={session.layout.tabs.all}>
|
||||
<For each={tabs().all()}>
|
||||
{(tab) => {
|
||||
const [file] = createResource(
|
||||
() => tab,
|
||||
|
|
@ -579,7 +647,7 @@ export default function Page() {
|
|||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<Show when={session.layout.tabs.active}>
|
||||
<Show when={tabs().active()}>
|
||||
<div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
|
|
@ -639,25 +707,21 @@ export default function Page() {
|
|||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
|
||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={session.terminal.all().map((t) => t.id)}>
|
||||
<For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
|
||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip value="New Terminal" class="flex items-center">
|
||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
|
||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<For each={session.terminal.all()}>
|
||||
{(terminal) => (
|
||||
<Tabs.Content value={terminal.id}>
|
||||
<Terminal
|
||||
pty={terminal}
|
||||
onCleanup={session.terminal.update}
|
||||
onConnectError={() => session.terminal.clone(terminal.id)}
|
||||
/>
|
||||
<For each={terminal.all()}>
|
||||
{(pty) => (
|
||||
<Tabs.Content value={pty.id}>
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -665,9 +729,9 @@ export default function Page() {
|
|||
<DragOverlay>
|
||||
<Show when={store.activeTerminalDraggable}>
|
||||
{(draggedId) => {
|
||||
const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId()))
|
||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||
return (
|
||||
<Show when={terminal()}>
|
||||
<Show when={pty()}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{t().title}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue