whitespace + imports

This commit is contained in:
Simon Klee
2026-04-20 10:05:31 +02:00
parent 3419eac1f2
commit 98db70c62e
27 changed files with 324 additions and 74 deletions

View File

@@ -16,13 +16,13 @@ import path from "path"
import { pathToFileURL } from "url"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Flag } from "@/flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util"
import { Filesystem } from "@/util"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Agent } from "../../agent/agent"
import { Permission } from "../../permission"
import { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { AppRuntime } from "@/effect/app-runtime"
import type { RunDemo } from "./run/types"
@@ -715,7 +715,7 @@ export const RunCommand = cmd({
const model = pick(args.model)
const { runInteractiveLocalMode } = await runtimeTask
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("../../server/server")
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch
@@ -744,7 +744,7 @@ export const RunCommand = cmd({
await bootstrap(directory ?? root, async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("../../server/server")
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch

View File

@@ -21,7 +21,7 @@ import {
onMount,
type Accessor,
} from "solid-js"
import * as Locale from "../../../util/locale"
import * as Locale from "@/util/locale"
import {
createPromptHistory,
isExitCommand,

View File

@@ -12,7 +12,7 @@
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
// arms the leader, second press within the timeout fires the action.
import type { KeyBinding } from "@opentui/core"
import * as Keybind from "../../../util/keybind"
import * as Keybind from "@/util/keybind"
import type { FooterKeybinds, RunPrompt } from "./types"
const HISTORY_LIMIT = 200

View File

@@ -9,7 +9,7 @@
// Also wires SIGINT so Ctrl-c during a turn triggers the two-press exit
// sequence through RunFooter.requestExit().
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import * as Locale from "../../../util/locale"
import * as Locale from "@/util/locale"
import { withRunSpan } from "./otel"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"

View File

@@ -9,7 +9,7 @@
// and tracks per-turn wall-clock duration for the footer status line.
//
// Resolves when the footer closes and all in-flight work finishes.
import * as Locale from "../../../util/locale"
import * as Locale from "@/util/locale"
import { isExitCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunPrompt } from "./types"

View File

@@ -51,10 +51,9 @@ function commitMarkdownBlocks(input: {
return false
}
const prev = input.renderable._blockStates[input.startBlock - 1]
const next = input.renderable._blockStates[input.endBlockExclusive]
const start = Math.max(0, first.renderable.y - (prev?.marginBottom ?? 0))
const end = last.renderable.y + last.renderable.height + (next ? 0 : (last.marginBottom ?? 0))
const start = first.renderable.y
const end = next ? next.renderable.y : last.renderable.y + last.renderable.height + (last.marginBottom ?? 0)
input.surface.commitRows(start, end, {
trailingNewline: input.trailingNewline,
@@ -62,6 +61,18 @@ function commitMarkdownBlocks(input: {
return true
}
function wantsSpacer(prev: StreamCommit | undefined, next: StreamCommit): boolean {
if (!prev) {
return false
}
if (sameEntryGroup(prev, next)) {
return false
}
return !(prev.kind === "tool" && prev.phase === "start")
}
export class RunScrollbackStream {
private tail: StreamCommit | undefined
private active: ActiveEntry | undefined
@@ -238,14 +249,14 @@ export class RunScrollbackStream {
const body = entryBody(commit)
if (body.type === "none") {
if (entryDone(commit)) {
await this.finishActive(entryFlags(commit).trailingNewline)
await this.finishActive(false)
}
this.tail = commit
return
}
if (this.wrote && !same) {
if (this.wrote && wantsSpacer(this.tail, commit)) {
this.renderer.writeToScrollback(spacerWriter())
}
@@ -256,7 +267,7 @@ export class RunScrollbackStream {
) {
await this.writeStreaming(commit, body)
if (entryDone(commit)) {
await this.finishActive(entryFlags(commit).trailingNewline)
await this.finishActive(false)
}
this.wrote = true
this.tail = commit

View File

@@ -25,7 +25,7 @@
// event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view.
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "../../../util/locale"
import * as Locale from "@/util/locale"
import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@@ -492,11 +492,19 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
if (sent === 0) {
chunk = chunk.replace(/^\n+/, "")
// Some models emit a standalone whitespace token before real content.
// Keep buffering until we have visible text so scrollback doesn't get a blank row.
if (!chunk.trim()) {
return
}
if (kind === "reasoning" && chunk) {
chunk = `Thinking: ${chunk.replace(/\[REDACTED\]/g, "")}`
}
if (kind === "assistant" && chunk) {
chunk = stripEcho(data, msg, chunk)
if (!chunk.trim()) {
return
}
}
}

View File

@@ -19,8 +19,8 @@ import {
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import * as Locale from "../../../util/locale"
import { logo } from "../../logo"
import * as Locale from "@/util/locale"
import { logo } from "@/cli/logo"
import type { RunEntryTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50

View File

@@ -1,5 +1,5 @@
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "../../../util/locale"
import * as Locale from "@/util/locale"
import {
bootstrapSessionData,
createSessionData,

View File

@@ -18,26 +18,26 @@ import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import type * as Tool from "../../../tool/tool"
import type { ApplyPatchTool } from "../../../tool/apply_patch"
import type { BashTool } from "../../../tool/bash"
import type { CodeSearchTool } from "../../../tool/codesearch"
import type { EditTool } from "../../../tool/edit"
import type { GlobTool } from "../../../tool/glob"
import type { GrepTool } from "../../../tool/grep"
import type { InvalidTool } from "../../../tool/invalid"
import type { LspTool } from "../../../tool/lsp"
import type { PlanExitTool } from "../../../tool/plan"
import type { QuestionTool } from "../../../tool/question"
import type { ReadTool } from "../../../tool/read"
import type { SkillTool } from "../../../tool/skill"
import type { TaskTool } from "../../../tool/task"
import type { TodoWriteTool } from "../../../tool/todo"
import type { WebFetchTool } from "../../../tool/webfetch"
import type { WebSearchTool } from "../../../tool/websearch"
import type { WriteTool } from "../../../tool/write"
import { LANGUAGE_EXTENSIONS } from "../../../lsp/language"
import * as Locale from "../../../util/locale"
import type * as Tool from "@/tool/tool"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { BashTool } from "@/tool/bash"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { EditTool } from "@/tool/edit"
import type { GlobTool } from "@/tool/glob"
import type { GrepTool } from "@/tool/grep"
import type { InvalidTool } from "@/tool/invalid"
import type { LspTool } from "@/tool/lsp"
import type { PlanExitTool } from "@/tool/plan"
import type { QuestionTool } from "@/tool/question"
import type { ReadTool } from "@/tool/read"
import type { SkillTool } from "@/tool/skill"
import type { TaskTool } from "@/tool/task"
import type { TodoWriteTool } from "@/tool/todo"
import type { WebFetchTool } from "@/tool/webfetch"
import type { WebSearchTool } from "@/tool/websearch"
import type { WriteTool } from "@/tool/write"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import * as Locale from "@/util/locale"
import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {

View File

@@ -13,7 +13,7 @@
// active based on the env var, and subsequent calls return the cached result.
import fs from "fs"
import path from "path"
import { Global } from "../../../global"
import { Global } from "@/global"
export type Trace = {
write(type: string, data?: unknown): void

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { entryBody, entryCanStream, entryDone } from "../../../src/cli/cmd/run/entry.body"
import type { StreamCommit } from "../../../src/cli/cmd/run/types"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
import type { StreamCommit } from "@/cli/cmd/run/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input

View File

@@ -1,7 +1,7 @@
import { afterEach, expect, test } from "bun:test"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunFooter } from "../../../src/cli/cmd/run/footer"
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
import { RunFooter } from "@/cli/cmd/run/footer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
const decoder = new TextDecoder()
const active: Array<{ footer?: RunFooter; renderer: TestRenderer }> = []
@@ -149,3 +149,77 @@ test("run footer keeps active streamed assistant content across width resize", a
lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
}
})
test("run footer keeps tool start rows tight with following reasoning", async () => {
const out = await createTestRenderer({
width: 80,
height: 24,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const footer = createFooter(out.renderer)
active.push({ footer, renderer: out.renderer })
const lib = Reflect.get(out.renderer, "lib") as {
commitSplitFooterSnapshot: (...args: unknown[]) => unknown
}
const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
const payloads: string[] = []
lib.commitSplitFooterSnapshot = (...args) => {
const snapshot = args[1] as {
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
}
payloads.push(decoder.decode(snapshot.getRealCharBytes(true)))
return originalCommitSplitFooterSnapshot(...args)
}
try {
footer.append({
kind: "tool",
source: "tool",
messageID: "msg-tool",
partID: "part-tool",
tool: "glob",
phase: "start",
text: "running glob",
toolState: "running",
part: {
id: "part-tool",
type: "tool",
tool: "glob",
callID: "call-tool",
messageID: "msg-tool",
sessionID: "session-1",
state: {
status: "running",
input: {
pattern: "**/run.ts",
},
time: {
start: Date.now(),
},
},
},
})
footer.append({
kind: "reasoning",
source: "reasoning",
messageID: "msg-reasoning",
partID: "part-reasoning",
phase: "progress",
text: "Thinking: Found it.",
})
await footer.idle()
const rows = payloads
.map((item) => item.replace(/ +/g, " ").trim())
.filter(Boolean)
expect(rows).toEqual(['✱ Glob "**/run.ts"', "_Thinking:_ Found it."])
} finally {
lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
}
})

View File

@@ -2,10 +2,10 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RunEntryContent } from "../../../src/cli/cmd/run/scrollback.writer"
import { RunFooterView } from "../../../src/cli/cmd/run/footer.view"
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
import type { StreamCommit } from "../../../src/cli/cmd/run/types"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RunFooterView } from "@/cli/cmd/run/footer.view"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
test("run footer view loads", () => {
expect(typeof RunFooterView).toBe("function")

View File

@@ -8,7 +8,7 @@ import {
permissionInfo,
permissionReject,
permissionRun,
} from "../../../src/cli/cmd/run/permission.shared"
} from "@/cli/cmd/run/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {

View File

@@ -8,8 +8,8 @@ import {
promptInfo,
promptKeys,
pushPromptHistory,
} from "../../../src/cli/cmd/run/prompt.shared"
import type { FooterKeybinds, RunPrompt } from "../../../src/cli/cmd/run/types"
} from "@/cli/cmd/run/prompt.shared"
import type { FooterKeybinds, RunPrompt } from "@/cli/cmd/run/types"
const keybinds: FooterKeybinds = {
leader: "ctrl+x",

View File

@@ -10,7 +10,7 @@ import {
questionStoreCustom,
questionSubmit,
questionSync,
} from "../../../src/cli/cmd/run/question.shared"
} from "@/cli/cmd/run/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {

View File

@@ -1,12 +1,12 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import {
resolveDiffStyle,
resolveFooterKeybinds,
resolveModelInfo,
resolveSessionInfo,
} from "../../../src/cli/cmd/run/runtime.boot"
import type { RunInput } from "../../../src/cli/cmd/run/types"
} from "@/cli/cmd/run/runtime.boot"
import type { RunInput } from "@/cli/cmd/run/types"
describe("run runtime boot", () => {
afterEach(() => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "../../../src/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../src/cli/cmd/run/types"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()

View File

@@ -1,7 +1,7 @@
import { afterEach, expect, test } from "bun:test"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "../../../src/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
type ClaimedCommit = {
snapshot: {
@@ -142,7 +142,7 @@ test("completes markdown replies without adding a second blank line above the fo
const progress = claimCommits(out.renderer)
try {
expect(progress).toHaveLength(1)
expect(progress[0]!.snapshot.height).toBe(4)
expect(progress[0]!.snapshot.height).toBe(5)
const rendered = decoder.decode(progress[0]!.snapshot.getRealCharBytes(true))
expect(rendered).toContain("Markdown Sample")
expect(rendered).toContain("Item 2")
@@ -165,6 +165,109 @@ test("completes markdown replies without adding a second blank line above the fo
}
})
test("streamed assistant final leaves newline ownership to the next entry", async () => {
const out = await createTestRenderer({
width: 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
treeSitterClient.setMockResult({ highlights: [] })
const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: false,
})
await scrollback.append({
kind: "assistant",
text: "hello",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "part-1",
})
destroyCommits(claimCommits(out.renderer))
await scrollback.append({
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
messageID: "msg-1",
partID: "part-1",
})
const final = claimCommits(out.renderer)
try {
expect(final).toHaveLength(1)
expect(final[0]!.trailingNewline).toBe(false)
} finally {
destroyCommits(final)
}
})
test("preserves blank rows between streamed markdown block commits", async () => {
const out = await createTestRenderer({
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
treeSitterClient.setMockResult({ highlights: [] })
const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: false,
})
await scrollback.append({
kind: "assistant",
text: "# Title\n\nPara 1\n\n",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "part-1",
})
const first = claimCommits(out.renderer)
expect(first).toHaveLength(1)
await scrollback.append({
kind: "assistant",
text: "> Quote",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "part-1",
})
const second = claimCommits(out.renderer)
expect(second).toHaveLength(0)
await scrollback.complete()
const final = claimCommits(out.renderer)
try {
expect(final).toHaveLength(1)
const rendered = [...first, ...final]
.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n"))
.join("")
expect(rendered).toContain("# Title\n\nPara 1\n\n> Quote")
} finally {
destroyCommits(first)
destroyCommits(final)
}
})
test("coalesces same-line tool progress into one snapshot", async () => {
const out = await createTestRenderer({
width: 80,
@@ -345,7 +448,6 @@ test("renders promoted task-result markdown without leading blank rows", async (
expect(commits.length).toBeGreaterThan(0)
const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("")
expect(rendered.startsWith("\n")).toBe(false)
expect(rendered.split("\n")[0]?.trim()).toBe("Location: `/tmp/run.ts`")
expect(rendered).toContain("Summary:")
expect(rendered).toContain("Local interactive mode")
} finally {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "../../../src/cli/cmd/run/session-data"
import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
@@ -77,6 +77,61 @@ describe("run session data", () => {
])
})
test("buffers whitespace-only initial assistant chunks until real content arrives", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
messageID: "msg-1",
sessionID: "session-1",
type: "text",
text: "",
time: { start: Date.now() },
},
},
}).data
let out = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "text",
delta: " ",
},
})
expect(out.commits).toEqual([])
data = out.data
out = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "text",
delta: "Found",
},
})
expect(out.commits).toEqual([
{
kind: "assistant",
text: " Found",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "txt-1",
},
])
})
test("drops user text when the delayed role resolves to user", () => {
let data = createSessionData()

View File

@@ -5,7 +5,7 @@ import {
sessionVariant,
type RunSession,
type SessionMessages,
} from "../../../src/cli/cmd/run/session.shared"
} from "@/cli/cmd/run/session.shared"
const model = {
providerID: "openai",

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "../../../src/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "../../../src/cli/cmd/run/types"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const events: FooterEvent[] = []

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { createSessionTransport } from "../../../src/cli/cmd/run/stream.transport"
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "../../../src/cli/cmd/run/types"
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
function defer<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { entryBody } from "../../../src/cli/cmd/run/entry.body"
import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentData,
clearFinishedSubagents,
@@ -7,7 +7,7 @@ import {
reduceSubagentData,
snapshotSelectedSubagentData,
snapshotSubagentData,
} from "../../../src/cli/cmd/run/subagent-data"
} from "@/cli/cmd/run/subagent-data"
function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
return commits.flatMap((item) => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { RGBA, SyntaxStyle } from "@opentui/core"
import { opaqueSyntaxStyle } from "../../../src/cli/cmd/run/theme"
import { opaqueSyntaxStyle } from "@/cli/cmd/run/theme"
describe("run theme", () => {
test("flattens subtle syntax alpha against the run background", () => {

View File

@@ -3,15 +3,15 @@ import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "../../../src/global"
import { Global } from "@/global"
import {
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "../../../src/cli/cmd/run/variant.shared"
import type { SessionMessages } from "../../../src/cli/cmd/run/session.shared"
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import { testEffect } from "../../lib/effect"
const model = {