Skip to content
Open
31 changes: 23 additions & 8 deletions packages/opencode/src/cli/cmd/export.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import type { Argv } from "yargs"
import { Session } from "../../session"
import { SessionID } from "../../session/schema"
import { Config } from "../../config/config"
import { datetime, resolveLocale, t, type Locale } from "../../i18n"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"

function latest(locale: Locale) {
return t(locale, "cli.export.latest")
}

export function exportProgress(locale: Locale, session: string) {
return t(locale, "cli.export.progress", { session })
}

export function exportHint(locale: Locale, updated: number, id: string) {
return `${datetime(locale, updated)} • ${id.slice(-8)}`
}

export const ExportCommand = cmd({
command: "export [sessionID]",
describe: "export session data as JSON",
Expand All @@ -18,12 +32,13 @@ export const ExportCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const locale = resolveLocale((await Config.get()).locale)
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
process.stderr.write(exportProgress(locale, sessionID ?? latest(locale)) + "\n")

if (!sessionID) {
UI.empty()
prompts.intro("Export session", {
prompts.intro(t(locale, "cli.export.intro"), {
output: process.stderr,
})

Expand All @@ -33,10 +48,10 @@ export const ExportCommand = cmd({
}

if (sessions.length === 0) {
prompts.log.error("No sessions found", {
prompts.log.error(t(locale, "cli.export.none"), {
output: process.stderr,
})
prompts.outro("Done", {
prompts.outro(t(locale, "cli.export.done"), {
output: process.stderr,
})
return
Expand All @@ -45,12 +60,12 @@ export const ExportCommand = cmd({
sessions.sort((a, b) => b.time.updated - a.time.updated)

const selectedSession = await prompts.autocomplete({
message: "Select session to export",
message: t(locale, "cli.export.select"),
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
hint: exportHint(locale, session.time.updated, session.id),
})),
output: process.stderr,
})
Expand All @@ -61,7 +76,7 @@ export const ExportCommand = cmd({

sessionID = selectedSession

prompts.outro("Exporting session...", {
prompts.outro(t(locale, "cli.export.outro"), {
output: process.stderr,
})
}
Expand All @@ -81,7 +96,7 @@ export const ExportCommand = cmd({
process.stdout.write(JSON.stringify(exportData, null, 2))
process.stdout.write(EOL)
} catch (error) {
UI.error(`Session not found: ${sessionID!}`)
UI.error(t(locale, "cli.export.not_found", { session: sessionID! }))
process.exit(1)
}
})
Expand Down
19 changes: 14 additions & 5 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import { Server } from "../../server/server"
import { Config } from "../../config/config"
import { resolveLocale, t } from "../../i18n"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project"
import { Installation } from "../../installation"
import { bootstrap } from "../bootstrap"

export function serveWarning(locale: ReturnType<typeof resolveLocale>) {
return t(locale, "cli.serve.warning_unsecured")
}

export function serveListening(locale: ReturnType<typeof resolveLocale>, hostname: string, port: number) {
return t(locale, "cli.serve.listening", { hostname, port })
}

export const ServeCommand = cmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
handler: async (args) => {
const locale = await bootstrap(process.cwd(), async () => resolveLocale((await Config.get()).locale))
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
console.log(serveWarning(locale))
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
console.log(serveListening(locale, server.hostname, server.port))

await new Promise(() => {})
await server.stop()
Expand Down
30 changes: 24 additions & 6 deletions packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Session } from "../../session"
import { SessionID } from "../../session/schema"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Config } from "../../config/config"
import { resolveLocale, t, type Locale as Lang } from "../../i18n"
import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem"
Expand Down Expand Up @@ -58,15 +60,20 @@ export const SessionDeleteCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const locale = resolveLocale((await Config.get()).locale)
const sessionID = SessionID.make(args.sessionID)
try {
await Session.get(sessionID)
} catch {
UI.error(`Session not found: ${args.sessionID}`)
UI.error(t(locale, "cli.session.not_found", { session: args.sessionID }))
process.exit(1)
}
await Session.remove(sessionID)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
UI.println(
UI.Style.TEXT_SUCCESS_BOLD +
t(locale, "cli.session.deleted", { session: args.sessionID }) +
UI.Style.TEXT_NORMAL,
)
})
},
})
Expand All @@ -90,6 +97,7 @@ export const SessionListCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const locale = resolveLocale((await Config.get()).locale)
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]

if (sessions.length === 0) {
Expand All @@ -100,7 +108,7 @@ export const SessionListCommand = cmd({
if (args.format === "json") {
output = formatSessionJSON(sessions)
} else {
output = formatSessionTable(sessions)
output = formatSessionTable(sessions, locale)
}

const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
Expand All @@ -127,25 +135,35 @@ export const SessionListCommand = cmd({
},
})

function formatSessionTable(sessions: Session.Info[]): string {
function formatSessionTable(sessions: Session.Info[], locale: Lang): string {
const lines: string[] = []

const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length))
const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length))

const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated`
const id = t(locale, "cli.session.header.id")
const title = t(locale, "cli.session.header.title")
const updated = t(locale, "cli.session.header.updated")
const header = sessionHeader(locale, maxIdWidth, maxTitleWidth)
lines.push(header)
lines.push("─".repeat(header.length))
for (const session of sessions) {
const truncatedTitle = Locale.truncate(session.title, maxTitleWidth)
const timeStr = Locale.todayTimeOrDateTime(session.time.updated)
const timeStr = Locale.todayTimeOrDateTime(session.time.updated, locale)
const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}`
lines.push(line)
}

return lines.join(EOL)
}

export function sessionHeader(locale: Lang, maxIdWidth: number, maxTitleWidth: number) {
const id = t(locale, "cli.session.header.id")
const title = t(locale, "cli.session.header.title")
const updated = t(locale, "cli.session.header.updated")
return `${id}${" ".repeat(Math.max(1, maxIdWidth - id.length))} ${title}${" ".repeat(Math.max(1, maxTitleWidth - title.length))} ${updated}`
}

function formatSessionJSON(sessions: Session.Info[]): string {
const jsonData = sessions.map((session) => ({
id: session.id,
Expand Down
46 changes: 25 additions & 21 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { I18nProvider, useI18n } from "@tui/context/i18n"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
Expand Down Expand Up @@ -217,25 +218,27 @@ export function tui(input: {
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
<I18nProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</I18nProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
Expand Down Expand Up @@ -265,6 +268,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const i18n = useI18n()
const exit = useExit()
const promptRef = usePromptRef()
const routes: RouteMap = new Map()
Expand Down Expand Up @@ -694,7 +698,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Help",
title: i18n.t("tui.dialog.help.title"),
value: "help.show",
slash: {
name: "help",
Expand Down Expand Up @@ -833,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
route.navigate({ type: "home" })
toast.show({
variant: "info",
message: "The current session was deleted",
message: i18n.t("cli.session.deleted", { session: evt.properties.info.id }),
})
}
})
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ import { createMemo } from "solid-js"
import { useLocal } from "@tui/context/local"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useI18n } from "@tui/context/i18n"

export function DialogAgent() {
const local = useLocal()
const dialog = useDialog()
const i18n = useI18n()

const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
description: item.native ? i18n.t("tui.dialog.agent.native") : item.description,
}
}),
)

return (
<DialogSelect
title="Select agent"
title={i18n.t("tui.dialog.agent.title")}
current={local.agent.current().name}
options={options()}
onSelect={(option) => {
Expand Down
Loading
Loading