Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { useLanguage } from "@tui/context/language"
import { createMemo } from "solid-js"
import { useSDK } from "../context/sdk"

Expand All @@ -12,11 +13,12 @@ export function DialogSessionRename(props: DialogSessionRenameProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const { t } = useLanguage()
const session = createMemo(() => sync.session.get(props.session))

return (
<DialogPrompt
title="Rename Session"
title={t("tui.rename_session.title")}
value={session()?.title}
onConfirm={(value) => {
void sdk.client.session.update({
Expand All @@ -28,4 +30,4 @@ export function DialogSessionRename(props: DialogSessionRenameProps) {
onCancel={() => dialog.clear()}
/>
)
}
}
50 changes: 26 additions & 24 deletions packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,14 @@ import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { useLanguage } from "@tui/context/language"
import { usePromptStash, type StashEntry } from "./prompt/stash"

function getRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)

if (seconds < 60) return "just now"
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return Locale.datetime(timestamp)
}

function getStashPreview(input: string, maxLength: number = 50): string {
const firstLine = input.split("\n")[0].trim()
return Locale.truncate(firstLine, maxLength)
}

export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const { t } = useLanguage()
const keybind = useKeybind()

const [toDelete, setToDelete] = createSignal<number>()
Expand All @@ -42,19 +24,19 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
title: isDeleting ? t("tui.stash.confirm_delete", { key: keybind.print("stash_delete") }) : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
footer: lineCount > 1 ? t("tui.stash.line_count", { count: lineCount }) : undefined,
}
})
.toReversed()
})

return (
<DialogSelect
title="Stash"
title={t("tui.stash.title")}
options={options()}
onMove={() => {
setToDelete(undefined)
Expand All @@ -71,7 +53,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
keybind={[
{
keybind: keybind.all.stash_delete?.[0],
title: "delete",
title: t("tui.stash.delete"),
onTrigger: (option) => {
if (toDelete() === option.value) {
stash.remove(option.value)
Expand All @@ -85,3 +67,23 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
/>
)
}

function getRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)

if (seconds < 60) return Locale.datetime(timestamp)
if (minutes < 60) return `${minutes}m`
if (hours < 24) return `${hours}h`
if (days < 7) return `${days}d`
return Locale.datetime(timestamp)
}

function getStashPreview(input: string, maxLength: number = 50): string {
const firstLine = input.split("\n")[0].trim()
return Locale.truncate(firstLine, maxLength)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { useLanguage } from "@tui/context/language"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorData, errorMessage } from "@/util/error"
Expand All @@ -19,6 +20,18 @@ type Adaptor = {

const log = Log.Default.clone().tag("service", "tui-workspace")

const passthrough = (key: string, params?: Record<string, any>) => {
// Fallback: return key as-is when called outside reactive context
const dict: Record<string, string> = {
"tui.workspace.create_session_failed": "Failed to create workspace session",
"tui.workspace.restore_failed": "Failed to restore session: {{error}}",
"tui.workspace.restored": "Session restored into the new workspace",
}
let val = dict[key] ?? key
if (params) Object.entries(params).forEach(([k, v]) => { val = val.replace(`{{${k}}}`, String(v)) })
return val
}

function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
Expand All @@ -34,8 +47,10 @@ export async function openWorkspaceSession(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
t?: (key: string, params?: Record<string, any>) => string
workspaceID: string
}) {
const t = input.t ?? passthrough
const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
Expand All @@ -51,7 +66,7 @@ export async function openWorkspaceSession(input: {
})
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
message: t("tui.workspace.create_session_failed"),
variant: "error",
})
return
Expand All @@ -75,7 +90,7 @@ export async function openWorkspaceSession(input: {
status: result.response?.status,
})
input.toast.show({
message: "Failed to create workspace session",
message: t("tui.workspace.create_session_failed"),
variant: "error",
})
return
Expand All @@ -100,10 +115,12 @@ export async function restoreWorkspaceSession(input: {
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
t?: (key: string, params?: Record<string, any>) => string
workspaceID: string
sessionID: string
done?: () => void
}) {
const t = input.t ?? passthrough
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
Expand All @@ -126,7 +143,7 @@ export async function restoreWorkspaceSession(input: {
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
message: t("tui.workspace.restore_failed", { error: errorMessage(result?.error ?? "no response") }),
variant: "error",
})
return
Expand Down Expand Up @@ -161,7 +178,7 @@ export async function restoreWorkspaceSession(input: {
})

input.toast.show({
message: "Session restored into the new workspace",
message: t("tui.workspace.restored"),
variant: "success",
})
input.done?.()
Expand All @@ -175,6 +192,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const { t } = useLanguage()
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()

Expand All @@ -187,10 +205,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
.catch((err: any) => {
log.warn("Failed to load workspace adaptors", { error: err })
return undefined
})
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
message: t("tui.workspace.load_adaptors_failed"),
variant: "error",
})
return
Expand All @@ -204,19 +225,19 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
if (type) {
return [
{
title: `Creating ${type} workspace...`,
title: t("tui.workspace.creating", { type }),
value: "creating" as const,
description: "This can take a while for remote environments",
description: t("tui.workspace.creating_desc"),
},
]
}
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
title: t("tui.workspace.loading"),
value: "loading" as const,
description: "Fetching available workspace adaptors",
description: t("tui.workspace.fetching_adaptors"),
},
]
}
Expand All @@ -236,7 +257,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =

const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
toast.show({
message: "Creating workspace failed",
message: t("tui.workspace.create_failed"),
variant: "error",
})
log.error("workspace create request failed", {
Expand All @@ -255,7 +276,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
message: t("tui.workspace.create_failed_with_error", { error: errorMessage(result?.error ?? "no response") }),
variant: "error",
})
return
Expand All @@ -277,7 +298,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =

return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
title={creating() ? t("tui.workspace.creating_title") : t("tui.workspace.new_title")}
skipFilter={true}
options={options()}
onSelect={(option) => {
Expand All @@ -286,4 +307,4 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
}}
/>
)
}
}
31 changes: 21 additions & 10 deletions packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,30 @@ import { useSDK } from "../context/sdk"
import { useSync } from "@tui/context/sync"
import { useRoute } from "@tui/context/route"
import { useToast } from "../ui/toast"
import { useLanguage } from "@tui/context/language"
import path from "path"
import * as Log from "@/util/log"

const CREATE_SENTINEL = "__create_worktree__"

const log = Log.create({ service: "tui.worktree" })

export function DialogWorktree() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const route = useRoute()
const toast = useToast()
const { t } = useLanguage()
const [worktrees, setWorktrees] = createSignal<string[]>()
const [busy, setBusy] = createSignal<string>()

onMount(async () => {
dialog.setSize("medium")
const result = await sdk.client.worktree.list().catch(() => undefined)
const result = await sdk.client.worktree.list().catch((err: any) => {
log.warn("Failed to list worktrees", { error: err })
return undefined
})
setWorktrees(result?.data ?? [])
})

Expand All @@ -32,7 +40,7 @@ export function DialogWorktree() {

const list = worktrees()
if (!list) {
return [{ title: "Loading worktrees...", value: "__loading__" }]
return [{ title: t("tui.worktree.loading"), value: "__loading__" }]
}

const items = list.map((dir) => ({
Expand All @@ -44,28 +52,31 @@ export function DialogWorktree() {
return [
...items,
{
title: "+ Create new worktree",
title: t("tui.worktree.create_new"),
value: CREATE_SENTINEL,
description: undefined as string | undefined,
},
]
})

async function switchTo(directory: string) {
setBusy("Switching to worktree...")
setBusy(t("tui.worktree.switching"))
await sdk.client.instance.dispose().catch(() => {})
sdk.switchDirectory(directory)
await sync.bootstrap()
route.navigate({ type: "home" })
dialog.clear()
toast.show({ message: `Switched to ${path.basename(directory)}`, variant: "success" })
toast.show({ message: t("tui.worktree.switched", { name: path.basename(directory) }), variant: "success" })
}

async function create() {
setBusy("Creating worktree...")
const result = await sdk.client.worktree.create().catch(() => undefined)
setBusy(t("tui.worktree.creating"))
const result = await sdk.client.worktree.create().catch((err: any) => {
log.warn("Failed to create worktree", { error: err })
return undefined
})
if (!result?.data) {
toast.show({ message: "Failed to create worktree", variant: "error" })
toast.show({ message: t("tui.worktree.create_failed"), variant: "error" })
setBusy(undefined)
return
}
Expand All @@ -74,7 +85,7 @@ export function DialogWorktree() {

return (
<DialogSelect
title="Worktrees"
title={t("tui.worktree.title")}
options={options()}
skipFilter={true}
onSelect={(option) => {
Expand All @@ -87,4 +98,4 @@ export function DialogWorktree() {
}}
/>
)
}
}
Loading