mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 10:00:48 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 138339a88d | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 |
@@ -200,7 +200,6 @@ const layer = Layer.effect(
|
||||
.up({
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Token } from "../util/token"
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const COMPACTION_CHUNK_TOKENS = 32_000
|
||||
const SUMMARY_OUTPUT_TOKENS = 4_096
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
@@ -80,7 +82,8 @@ type Plan = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly model: Model
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly previousSummary?: string
|
||||
readonly context: readonly string[]
|
||||
readonly recent: string
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}
|
||||
@@ -199,6 +202,14 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
||||
...input.context,
|
||||
].join("\n\n")
|
||||
|
||||
const chunkContext = (context: readonly string[]) => {
|
||||
const value = context.filter(Boolean).join("\n\n")
|
||||
const size = COMPACTION_CHUNK_TOKENS * 4
|
||||
return Array.from({ length: Math.ceil(value.length / size) }, (_, index) =>
|
||||
value.slice(index * size, (index + 1) * size),
|
||||
)
|
||||
}
|
||||
|
||||
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
const selected = select(messages, tokens)
|
||||
if (!selected) return
|
||||
@@ -208,10 +219,8 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
||||
const summarizeRecent = !previousRecent && !selected.head
|
||||
return {
|
||||
prompt: buildPrompt({
|
||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||
}),
|
||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||
context: chunkContext(summarizeRecent ? [selected.recent] : [previousRecent, selected.head]),
|
||||
recent: summarizeRecent ? "" : selected.recent,
|
||||
}
|
||||
}
|
||||
@@ -235,62 +244,67 @@ const make = (dependencies: Dependencies) => {
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.sessionID,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
let summary = plan.previousSummary
|
||||
for (const [index, context] of plan.context.entries()) {
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
const publish = index === plan.context.length - 1
|
||||
yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
messages: [Message.user(buildPrompt({ previousSummary: summary, context: [context] }))],
|
||||
tools: [],
|
||||
generation: { maxTokens: SUMMARY_OUTPUT_TOKENS },
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
if (publish)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.sessionID,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const next = chunks.join("")
|
||||
if (failure || !next.trim())
|
||||
return yield* failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error: failure ?? { type: "compaction.failed", message: "Compaction produced no summary" },
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
summary = next
|
||||
}
|
||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
text: summary ?? "",
|
||||
recent: plan.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
|
||||
@@ -132,6 +132,8 @@ export const Plugin = {
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", state },
|
||||
})
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
|
||||
@@ -876,7 +876,7 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -936,6 +936,7 @@ describe("Config", () => {
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
"outside",
|
||||
"root",
|
||||
"parent",
|
||||
"directory",
|
||||
@@ -951,6 +952,8 @@ describe("Config", () => {
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
AbsolutePath.make(path.join(tmp.path, "opencode.json")),
|
||||
"root",
|
||||
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||
"parent",
|
||||
|
||||
@@ -151,7 +151,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(requests[0]?.generation?.maxTokens).toBe(4_096)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||
@@ -170,3 +170,53 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction rolls large histories through bounded requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = SessionV2.ID.make("ses_bounded_compaction")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "bounded-compaction",
|
||||
directory: "/project",
|
||||
title: "Bounded compaction",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((value) => (value ? Effect.succeed(value) : Effect.die("test session missing"))))
|
||||
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "a".repeat(300_000),
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_bounded_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests.length).toBeGreaterThan(1)
|
||||
expect(requests.every((request) => JSON.stringify(request.messages).length < 150_000)).toBe(true)
|
||||
expect(JSON.stringify(requests[1]?.messages)).toContain("manual summary")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -443,6 +443,12 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
@@ -454,6 +460,13 @@ describe("ShellTool", () => {
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
|
||||
description: idleCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
state: "completed",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3,14 +3,19 @@ title: "Migrate from V1"
|
||||
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
|
||||
---
|
||||
|
||||
<Note>
|
||||
The only intentional breaking changes in V2 are the server API and the plugin API. All other functionality is intended
|
||||
to remain compatible with V1.
|
||||
</Note>
|
||||
## Breaking changes
|
||||
|
||||
Existing config files, agent definitions, command definitions, skills, and other files in `.opencode/` should continue to
|
||||
work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than an expected
|
||||
migration requirement.
|
||||
V2 has three intentional breaking changes:
|
||||
|
||||
- [Plugins](#plugins) use a new plugin API.
|
||||
- The [server API and clients](#server-api-and-clients) have new contracts.
|
||||
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
|
||||
|
||||
All other functionality is intended to remain compatible with V1.
|
||||
|
||||
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
|
||||
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
|
||||
an expected migration requirement.
|
||||
|
||||
<Tip>
|
||||
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
|
||||
@@ -466,6 +471,58 @@ If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the appl
|
||||
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected
|
||||
project details. See [Instructions](/instructions).
|
||||
|
||||
## TUI configuration
|
||||
|
||||
V1 loaded `tui.json(c)` from the global config directory and from project directories discovered while walking up from
|
||||
the current directory. V2 instead stores CLI and TUI settings in one global file:
|
||||
|
||||
```text
|
||||
~/.config/opencode/cli.json
|
||||
```
|
||||
|
||||
The CLI owns this file. The background service does not load it, and V2 does not discover or merge project-local
|
||||
`tui.json(c)` or `cli.json` files.
|
||||
|
||||
The native V2 format groups related settings. For example:
|
||||
|
||||
```jsonc
|
||||
// V1: ~/.config/opencode/tui.json
|
||||
{
|
||||
"theme": "tokyonight",
|
||||
"scroll_speed": 2,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
// V2: ~/.config/opencode/cli.json
|
||||
{
|
||||
"theme": {
|
||||
"name": "tokyonight"
|
||||
},
|
||||
"scroll": {
|
||||
"speed": 2,
|
||||
"acceleration": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V2 migrates the global TUI configuration automatically. On the first CLI or TUI startup, when `cli.json` does not already
|
||||
exist, it:
|
||||
|
||||
- Reads `~/.config/opencode/tui.json`.
|
||||
- Reads persisted TUI preferences from the legacy `kv.json` state file.
|
||||
- Converts supported settings to the native grouped format and writes `~/.config/opencode/cli.json`.
|
||||
- Leaves the V1 files unchanged so V1 can continue using them.
|
||||
|
||||
Migration runs only while `cli.json` is absent. Once that file exists, V2 treats it as the source of truth and does not
|
||||
continually synchronize later changes from `tui.json` or `kv.json`. If you created `cli.json` before starting V2, merge any
|
||||
V1 settings you still need into it manually.
|
||||
|
||||
Project-local V1 TUI configuration is not migrated because V2 has no project-local CLI configuration. Move settings you
|
||||
still want into the global `cli.json`; when multiple projects used different values for the same setting, choose the
|
||||
global behavior you want V2 to use.
|
||||
|
||||
## Plugins
|
||||
|
||||
Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
|
||||
|
||||
@@ -53,7 +53,6 @@ import { DialogModel } from "./component/dialog-model"
|
||||
import { useConnected } from "./component/use-connected"
|
||||
import { DialogMcp } from "./component/dialog-mcp"
|
||||
import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -219,9 +218,7 @@ function fromV1(config: TuiConfigV1.Resolved): Config.Info {
|
||||
}
|
||||
|
||||
function isConfigInterface(config: Config.Interface | TuiConfigV1.Resolved): config is Config.Interface {
|
||||
return (
|
||||
"get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
|
||||
)
|
||||
return "get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
|
||||
}
|
||||
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
@@ -480,8 +477,7 @@ function App(props: {
|
||||
}) {
|
||||
const log = useLog({ component: "app" })
|
||||
const startup = useTuiStartup()
|
||||
const configContext = useConfig()
|
||||
const config = configContext.data
|
||||
const config = useConfig().data
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -591,12 +587,10 @@ function App(props: {
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
|
||||
})
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", true),
|
||||
)
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
@@ -855,16 +849,6 @@ function App(props: {
|
||||
},
|
||||
category: "Integration",
|
||||
},
|
||||
{
|
||||
name: "opencode.settings",
|
||||
title: "Open settings",
|
||||
slashName: "settings",
|
||||
enabled: configContext.writable,
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogConfig />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Setting = {
|
||||
title: string
|
||||
category: string
|
||||
description: string
|
||||
detail?: string
|
||||
path: string[]
|
||||
default: unknown
|
||||
values?: readonly unknown[]
|
||||
labels?: readonly string[]
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
format?: (value: unknown) => string
|
||||
}
|
||||
|
||||
const settings: Setting[] = [
|
||||
{
|
||||
title: "Theme",
|
||||
category: "Appearance",
|
||||
description: "Interface color theme",
|
||||
detail:
|
||||
"Choose the color theme used throughout OpenCode. Custom themes discovered from your config directory appear here alongside the built-in themes.",
|
||||
path: ["theme", "name"],
|
||||
default: "opencode",
|
||||
},
|
||||
{
|
||||
title: "Color mode",
|
||||
category: "Appearance",
|
||||
description: "Terminal color preference",
|
||||
detail:
|
||||
"Choose how OpenCode selects its colors. System follows your terminal preference, while dark and light keep the interface in a fixed mode.",
|
||||
path: ["theme", "mode"],
|
||||
default: "system",
|
||||
values: ["system", "dark", "light"],
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
category: "Appearance",
|
||||
description: "Interface motion",
|
||||
path: ["animations"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Tips",
|
||||
category: "Appearance",
|
||||
description: "Home screen hints",
|
||||
path: ["hints", "tips"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
description: "Getting-started guidance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
description: "Session sidebar visibility",
|
||||
path: ["session", "sidebar"],
|
||||
default: "auto",
|
||||
values: ["hide", "auto"],
|
||||
},
|
||||
{
|
||||
title: "Scrollbar",
|
||||
category: "Session",
|
||||
description: "Transcript scrollbar",
|
||||
path: ["session", "scrollbar"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Thinking",
|
||||
category: "Session",
|
||||
description: "Model reasoning by default",
|
||||
path: ["session", "thinking"],
|
||||
default: "hide",
|
||||
values: ["hide", "show"],
|
||||
},
|
||||
{
|
||||
title: "Grouping",
|
||||
category: "Session",
|
||||
description: "Related transcript items",
|
||||
path: ["session", "grouping"],
|
||||
default: "auto",
|
||||
values: ["none", "auto"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
description: "Diff presentation",
|
||||
path: ["diffs", "view"],
|
||||
default: "auto",
|
||||
values: ["auto", "split", "unified"],
|
||||
},
|
||||
{
|
||||
title: "Wrapping",
|
||||
category: "Diffs",
|
||||
description: "Long diff lines",
|
||||
path: ["diffs", "wrap"],
|
||||
default: "word",
|
||||
values: ["none", "word"],
|
||||
},
|
||||
{
|
||||
title: "File tree",
|
||||
category: "Diffs",
|
||||
description: "Diff file navigation",
|
||||
path: ["diffs", "tree"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Single patch",
|
||||
category: "Diffs",
|
||||
description: "Only the selected patch",
|
||||
path: ["diffs", "single"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Scroll speed",
|
||||
category: "Input",
|
||||
description: "Distance per input tick",
|
||||
path: ["scroll", "speed"],
|
||||
default: 3,
|
||||
step: 0.25,
|
||||
min: 0.25,
|
||||
max: 10,
|
||||
format: (value) => Number(value).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: "Acceleration",
|
||||
category: "Input",
|
||||
description: "Repeated scrolling",
|
||||
path: ["scroll", "acceleration"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Mouse",
|
||||
category: "Input",
|
||||
description: "Terminal mouse capture",
|
||||
path: ["mouse"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Editor context",
|
||||
category: "Input",
|
||||
description: "Active selection in prompts",
|
||||
path: ["prompt", "editor"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Large pastes",
|
||||
category: "Input",
|
||||
description: "Paste display style",
|
||||
path: ["prompt", "paste"],
|
||||
default: "compact",
|
||||
values: ["compact", "full"],
|
||||
},
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
description: "Wait after leader key",
|
||||
path: ["leader", "timeout"],
|
||||
default: 2000,
|
||||
step: 250,
|
||||
min: 250,
|
||||
max: 10000,
|
||||
format: (value) => `${value} ms`,
|
||||
},
|
||||
{
|
||||
title: "Attention",
|
||||
category: "Alerts",
|
||||
description: "Alerts when input is needed",
|
||||
path: ["attention", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
category: "Alerts",
|
||||
description: "System notifications",
|
||||
path: ["attention", "notifications"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Sounds",
|
||||
category: "Alerts",
|
||||
description: "Attention sounds",
|
||||
path: ["attention", "sound"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Volume",
|
||||
category: "Alerts",
|
||||
description: "Attention sound level",
|
||||
path: ["attention", "volume"],
|
||||
default: 0.4,
|
||||
step: 0.1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
format: (value) => `${Math.round(Number(value) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: "Window title",
|
||||
category: "Terminal",
|
||||
description: "Update terminal title",
|
||||
path: ["terminal", "title"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogConfig() {
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme } = themeState
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
onMount(() => {
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
const value = (setting: Setting) => {
|
||||
const current = setting.path.reduce<unknown>((result, key) => {
|
||||
if (!result || typeof result !== "object") return undefined
|
||||
return (result as Record<string, unknown>)[key]
|
||||
}, config.data)
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
|
||||
return current ?? setting.default
|
||||
}
|
||||
const values = (setting: Setting) =>
|
||||
setting.path.join(".") === "theme.name"
|
||||
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
: setting.values
|
||||
const display = (setting: Setting) => {
|
||||
const current = value(setting)
|
||||
if (setting.format) return setting.format(current)
|
||||
const index = setting.values?.indexOf(current)
|
||||
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
|
||||
}
|
||||
const rows = createMemo(() =>
|
||||
settings.map((setting, index) => ({
|
||||
setting,
|
||||
index,
|
||||
heading: index === 0 || settings[index - 1].category !== setting.category,
|
||||
})),
|
||||
)
|
||||
const split = createMemo(() => dimensions().width >= 110)
|
||||
const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
|
||||
|
||||
function move(direction: number) {
|
||||
const next = (selected() + direction + settings.length) % settings.length
|
||||
setSelected(next)
|
||||
queueMicrotask(() => {
|
||||
if (!scroll) return
|
||||
const row =
|
||||
next +
|
||||
settings.slice(0, next + 1).filter((setting, index) => {
|
||||
return index === 0 || settings[index - 1].category !== setting.category
|
||||
}).length
|
||||
if (row < scroll.scrollTop) scroll.scrollTo(row)
|
||||
if (row >= scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(row - scroll.viewport.height + 1)
|
||||
})
|
||||
}
|
||||
|
||||
async function change(direction: number) {
|
||||
if (saving()) return
|
||||
const setting = settings[selected()]
|
||||
const current = value(setting)
|
||||
const choices = values(setting)
|
||||
const next = choices
|
||||
? choices[(choices.indexOf(current) + direction + choices.length) % choices.length]
|
||||
: Math.min(setting.max!, Math.max(setting.min!, Number(current) + direction * setting.step!))
|
||||
if (next === current) return
|
||||
setSaving(true)
|
||||
await config
|
||||
.update((draft) => {
|
||||
const parent = setting.path.slice(0, -1).reduce<Record<string, unknown>>((result, key) => {
|
||||
if (!result[key] || typeof result[key] !== "object") result[key] = {}
|
||||
return result[key] as Record<string, unknown>
|
||||
}, draft)
|
||||
parent[setting.path.at(-1)!] = next
|
||||
})
|
||||
.catch(toast.error)
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous setting",
|
||||
group: "Settings",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next setting",
|
||||
group: "Settings",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{ key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(-1) },
|
||||
{ key: "right", desc: "Next value", group: "Settings", cmd: () => void change(1) },
|
||||
{ key: "return", desc: "Next value", group: "Settings", cmd: () => void change(1) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" height={height() + 1}>
|
||||
<box width={split() ? "54%" : "100%"} paddingLeft={4} paddingRight={split() ? 3 : 4} paddingBottom={1}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD} paddingBottom={1}>
|
||||
Settings
|
||||
</text>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<>
|
||||
<Show when={row.heading}>
|
||||
<box paddingTop={row.index === 0 ? 0 : 1}>
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{row.setting.category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" height={1}>
|
||||
<text
|
||||
width={25}
|
||||
fg={row.index === selected() ? theme.text : theme.textMuted}
|
||||
attributes={row.index === selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{row.setting.title}
|
||||
</text>
|
||||
<box flexGrow={1} flexDirection="row" justifyContent="flex-end">
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.textMuted}>{row.index === selected() ? "‹ " : " "}</text>
|
||||
<text
|
||||
fg={row.index === selected() ? theme.primary : theme.textMuted}
|
||||
attributes={row.index === selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{display(row.setting)}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{row.index === selected() ? " ›" : " "}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<Show when={split()}>
|
||||
<box
|
||||
position="relative"
|
||||
top={-1}
|
||||
width="46%"
|
||||
height={height() + 2}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{settings[selected()].title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{settings[selected()].detail ?? settings[selected()].description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -179,7 +179,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
const ConfigContext = createContext<{
|
||||
data: Resolved
|
||||
update: Interface["update"]
|
||||
writable: boolean
|
||||
}>()
|
||||
|
||||
export function ConfigProvider(props: {
|
||||
@@ -196,9 +195,7 @@ export function ConfigProvider(props: {
|
||||
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
|
||||
return info
|
||||
}
|
||||
return (
|
||||
<ConfigContext.Provider value={{ data: config, update, writable: !!host }}>{props.children}</ConfigContext.Provider>
|
||||
)
|
||||
return <ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
|
||||
}
|
||||
|
||||
export function useConfig() {
|
||||
|
||||
@@ -142,7 +142,6 @@ export const Definitions = {
|
||||
messages_copy: keybind("<leader>y", "Copy message"),
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
messages_redo: keybind("<leader>r", "Redo message"),
|
||||
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
@@ -341,7 +340,6 @@ export const CommandMap = {
|
||||
messages_copy: "messages.copy",
|
||||
messages_undo: "session.undo",
|
||||
messages_redo: "session.redo",
|
||||
messages_toggle_conceal: "session.toggle.conceal",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
|
||||
@@ -129,15 +129,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
if (theme) setStore("active", theme)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = config.theme?.mode
|
||||
if (mode === "dark" || mode === "light") {
|
||||
pin(mode)
|
||||
return
|
||||
}
|
||||
if (mode === "system" && store.lock !== undefined) free()
|
||||
})
|
||||
|
||||
function syncCustomThemes() {
|
||||
return themes
|
||||
.discover()
|
||||
|
||||
@@ -25,7 +25,6 @@ type Shortcuts = {
|
||||
messagesLast: TipShortcut
|
||||
messagesPageDown: TipShortcut
|
||||
messagesPageUp: TipShortcut
|
||||
messagesToggleConceal: TipShortcut
|
||||
modelCycleRecent: TipShortcut
|
||||
modelList: TipShortcut
|
||||
sessionExport: TipShortcut
|
||||
@@ -115,7 +114,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
messagesLast: configShortcut(props.api, "session.last"),
|
||||
messagesPageDown: configShortcut(props.api, "session.page.down"),
|
||||
messagesPageUp: configShortcut(props.api, "session.page.up"),
|
||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||
modelList: useCommandShortcut("model.list"),
|
||||
sessionExport: configShortcut(props.api, "session.export"),
|
||||
@@ -267,7 +265,6 @@ const TIPS: Tip[] = [
|
||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
|
||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
|
||||
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
|
||||
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
|
||||
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
|
||||
"Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
|
||||
(shortcuts) =>
|
||||
|
||||
@@ -88,7 +88,6 @@ const sessionBindingCommands = [
|
||||
"session.undo",
|
||||
"session.redo",
|
||||
"session.sidebar.toggle",
|
||||
"session.toggle.conceal",
|
||||
"session.toggle.thinking",
|
||||
"session.toggle.scrollbar",
|
||||
"session.toggle.exploration_grouping",
|
||||
@@ -121,7 +120,6 @@ const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"]
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
conceal: () => boolean
|
||||
thinkingMode: () => ThinkingMode
|
||||
showThinking: () => boolean
|
||||
groupExploration: () => boolean
|
||||
@@ -198,7 +196,6 @@ export function Session() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => true)
|
||||
@@ -471,15 +468,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
category: "Session",
|
||||
run: () => {
|
||||
setConceal((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
@@ -507,7 +495,7 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools",
|
||||
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
|
||||
value: "session.toggle.exploration_grouping",
|
||||
category: "Session",
|
||||
run: () => {
|
||||
@@ -865,7 +853,6 @@ export function Session() {
|
||||
return contentWidth()
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
groupExploration,
|
||||
@@ -1244,21 +1231,27 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
}
|
||||
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const { theme } = useTheme()
|
||||
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
||||
const completion = () => metadata()?.source === "subagent"
|
||||
const source = () => stringValue(metadata()?.source)
|
||||
const completion = () => source() === "subagent" || source() === "shell"
|
||||
const state = () => stringValue(metadata()?.state)
|
||||
const agent = () => Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent")
|
||||
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||
const text = () => {
|
||||
if (props.message.type === "system") return props.message.text
|
||||
if (props.message.type === "synthetic") return props.message.description ?? ""
|
||||
return ""
|
||||
}
|
||||
const description = () => (source() === "shell" ? text().replace(/\s+/g, " ").trim() : text())
|
||||
const status = () => {
|
||||
if (state() === "completed") return "finished"
|
||||
if (state() === "error") return "failed"
|
||||
return state() ?? "finished"
|
||||
}
|
||||
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||
const suffix = () =>
|
||||
Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - Bun.stringWidth(heading())))
|
||||
const color = () => {
|
||||
if (state() === "error") return theme.error
|
||||
if (state() === "cancelled") return theme.warning
|
||||
@@ -1274,11 +1267,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
}
|
||||
>
|
||||
<box marginLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: color() }}>
|
||||
{state() === "completed" ? "↳" : "!"} {agent()} {status()}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {text()}</span>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: theme.textMuted }}>{suffix()}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -1329,7 +1320,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
internalBlockMode="top-level"
|
||||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -1747,7 +1738,7 @@ function ReasoningPart(props: {
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={summary().body}
|
||||
conceal={ctx.conceal()}
|
||||
conceal={false}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
@@ -1813,7 +1804,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
|
||||
@@ -914,12 +914,6 @@ function getSyntaxRules(theme: Theme) {
|
||||
foreground: theme.text,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["conceal"],
|
||||
style: {
|
||||
foreground: theme.textMuted,
|
||||
},
|
||||
},
|
||||
// Additional common highlight groups
|
||||
{
|
||||
scope: ["string.special", "string.special.url"],
|
||||
|
||||
@@ -63,6 +63,24 @@ export function truncate(str: string, len: number): string {
|
||||
return str.slice(0, len - 1) + "…"
|
||||
}
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function truncateWidth(str: string, width: number): string {
|
||||
if (width <= 0) return ""
|
||||
if (Bun.stringWidth(str) <= width) return str
|
||||
if (width === 1) return "…"
|
||||
|
||||
const result: string[] = []
|
||||
let used = 0
|
||||
for (const item of graphemeSegmenter.segment(str)) {
|
||||
const next = Bun.stringWidth(item.segment)
|
||||
if (used + next > width - 1) break
|
||||
result.push(item.segment)
|
||||
used += next
|
||||
}
|
||||
return result.join("") + "…"
|
||||
}
|
||||
|
||||
export function truncateLeft(str: string, len: number): string {
|
||||
if (str.length <= len) return str
|
||||
return "…" + str.slice(-(len - 1))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Locale } from "../../src/util/locale"
|
||||
|
||||
test("truncates text from the right by terminal width", () => {
|
||||
expect(Locale.truncateWidth("abcdefgh", 5)).toBe("abcd…")
|
||||
expect(Locale.truncateWidth("ab界cd", 5)).toBe("ab界…")
|
||||
expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…")
|
||||
expect(Locale.truncateWidth("abcdefgh", 0)).toBe("")
|
||||
})
|
||||
Reference in New Issue
Block a user