mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d2d43e8c4 | |||
| 9c354995a5 | |||
| 88db6d22f8 | |||
| 13b2917f74 | |||
| 5469155eed |
@@ -7,6 +7,8 @@
|
|||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.13",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
|
"dev:demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts --demo",
|
||||||
|
"dev:run-demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts run --interactive --demo",
|
||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"build": "bun run script/build.ts",
|
"build": "bun run script/build.ts",
|
||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
|
"dev:demo": "bun run --conditions=browser ./src/index.ts --demo",
|
||||||
|
"dev:run-demo": "bun run --conditions=browser ./src/index.ts run --interactive --demo",
|
||||||
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
|
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
|
||||||
"db": "bun drizzle-kit"
|
"db": "bun drizzle-kit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
export const SAMPLE_MARKDOWN = [
|
||||||
|
"# Direct Mode Demo",
|
||||||
|
"",
|
||||||
|
"This is a realistic assistant response for direct-mode formatting checks.",
|
||||||
|
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
|
||||||
|
"",
|
||||||
|
"## Summary",
|
||||||
|
"",
|
||||||
|
"- Restored the final markdown flush so the last block is committed on idle.",
|
||||||
|
"- Switched markdown scrollback commits back to top-level block boundaries.",
|
||||||
|
"- Added footer-level regression coverage for split-footer rendering.",
|
||||||
|
"",
|
||||||
|
"## Status",
|
||||||
|
"",
|
||||||
|
"| Area | Before | After | Notes |",
|
||||||
|
"| --- | --- | --- | --- |",
|
||||||
|
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
|
||||||
|
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
|
||||||
|
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
|
||||||
|
"",
|
||||||
|
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
"const result = { markdown: true, tables: 2, stable: true }",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"## Files",
|
||||||
|
"",
|
||||||
|
"| File | Change |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
|
||||||
|
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
|
||||||
|
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
|
||||||
|
"",
|
||||||
|
"Next step: run `/fmt table` if you want a tighter table-only sample.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
export const SAMPLE_TABLE = [
|
||||||
|
"# Table Sample",
|
||||||
|
"",
|
||||||
|
"| Kind | Example | Notes |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
|
||||||
|
"| Unicode | `漢字` | Wide characters should remain aligned |",
|
||||||
|
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
|
||||||
|
"| Status | done | Final row should still appear after idle |",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
export const MARKDOWN_PATTERNS = {
|
||||||
|
"md-code": [
|
||||||
|
"# Interleaved Code",
|
||||||
|
"",
|
||||||
|
"Start with a short conclusion before any code appears.",
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
"export function parse(input: string) {",
|
||||||
|
" return input.trim().split(/\\s+/)",
|
||||||
|
"}",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Then continue with prose immediately after the code block. This should not inherit code styling or indentation.",
|
||||||
|
"",
|
||||||
|
"```tsx",
|
||||||
|
"<Show when={props.enabled}>",
|
||||||
|
" <markdown content={props.text} streaming />",
|
||||||
|
"</Show>",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Final paragraph after a second fence with `inline code`, **bold text**, and _emphasis_ mixed together.",
|
||||||
|
].join("\n"),
|
||||||
|
"md-fence": [
|
||||||
|
"# Fence Boundaries",
|
||||||
|
"",
|
||||||
|
"The renderer should recover cleanly around multiple fences and nearby paragraphs.",
|
||||||
|
"",
|
||||||
|
"```bash",
|
||||||
|
"bun run test -- --grep markdown",
|
||||||
|
"```",
|
||||||
|
"Text directly after a fence.",
|
||||||
|
"```json",
|
||||||
|
"{",
|
||||||
|
' "status": "ok",',
|
||||||
|
' "items": ["one", "two"]',
|
||||||
|
"}",
|
||||||
|
"```",
|
||||||
|
"Trailing paragraph after JSON. The next fence intentionally has no language.",
|
||||||
|
"```",
|
||||||
|
"plain fenced text",
|
||||||
|
"with multiple lines",
|
||||||
|
"```",
|
||||||
|
].join("\n"),
|
||||||
|
"md-list": [
|
||||||
|
"# Lists With Code",
|
||||||
|
"",
|
||||||
|
"1. First ordered item with `inline code`.",
|
||||||
|
"2. Second ordered item before a nested list:",
|
||||||
|
" - Nested bullet with a long phrase that should wrap without swallowing the marker or changing indentation.",
|
||||||
|
" - Nested bullet before fenced code:",
|
||||||
|
"",
|
||||||
|
" ```ts",
|
||||||
|
" const nested = true",
|
||||||
|
" ```",
|
||||||
|
"",
|
||||||
|
"3. Third ordered item after the nested fence.",
|
||||||
|
"",
|
||||||
|
"- Top-level bullet after ordered list.",
|
||||||
|
"- Another bullet with a paragraph below.",
|
||||||
|
"",
|
||||||
|
" Continuation paragraph should stay associated with the bullet without becoming code.",
|
||||||
|
].join("\n"),
|
||||||
|
"md-table-code": [
|
||||||
|
"# Tables And Code",
|
||||||
|
"",
|
||||||
|
"| Case | Input | Expected |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
"| Inline code | `const x = 1` | stays inline |",
|
||||||
|
"| Escaped pipe | `A\\|B` | one cell |",
|
||||||
|
"| Long token | `LongTokenWithoutNaturalBreaks_1234567890_abcdefghijklmnopqrstuvwxyz` | wraps or scrolls predictably |",
|
||||||
|
"",
|
||||||
|
"A code block follows the table:",
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
"const rows = [",
|
||||||
|
' { case: "inline code", expected: "stays inline" },',
|
||||||
|
' { case: "escaped pipe", expected: "one cell" },',
|
||||||
|
"]",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"And then another compact table:",
|
||||||
|
"",
|
||||||
|
"| A | B |",
|
||||||
|
"| - | - |",
|
||||||
|
"| done | yes |",
|
||||||
|
].join("\n"),
|
||||||
|
"md-inline": [
|
||||||
|
"# Inline Markdown",
|
||||||
|
"",
|
||||||
|
"This paragraph mixes [a normal link](https://opencode.ai), `https://example.com/code-link`, `inline code`, **strong**, _emphasis_, and ~~strikethrough~~.",
|
||||||
|
"",
|
||||||
|
"> Blockquote with `inline code` and [a link](https://example.com) should keep quote styling while wrapping.",
|
||||||
|
"",
|
||||||
|
"A horizontal rule follows.",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"After the rule, text should resume normal spacing.",
|
||||||
|
].join("\n"),
|
||||||
|
"md-kitchen": [
|
||||||
|
"# Markdown Kitchen Sink",
|
||||||
|
"",
|
||||||
|
"This combines headings, paragraphs, lists, blockquotes, tables, inline code, and multiple code fences.",
|
||||||
|
"",
|
||||||
|
"## Steps",
|
||||||
|
"",
|
||||||
|
"1. Read the response.",
|
||||||
|
"2. Notice `inline code` before a block.",
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
"type Result = { ok: boolean; reason?: string }",
|
||||||
|
"const result: Result = { ok: true }",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"3. Continue the list after the block.",
|
||||||
|
"",
|
||||||
|
"> Quoted note after the list. It should not merge into the previous item.",
|
||||||
|
"",
|
||||||
|
"| Feature | Stress |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| Markdown | prose/code/table interleave |",
|
||||||
|
"| Renderer | wrapping and spacing |",
|
||||||
|
"",
|
||||||
|
"```diff",
|
||||||
|
"- const renderer = oldMarkdown",
|
||||||
|
"+ const renderer = experimentalMarkdown",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Final paragraph with [docs](https://opencode.ai/docs) and `https://example.com/from-code`.",
|
||||||
|
].join("\n"),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const MARKDOWN_PATTERN_KINDS = Object.keys(MARKDOWN_PATTERNS) as Array<keyof typeof MARKDOWN_PATTERNS>
|
||||||
@@ -19,9 +19,11 @@ import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
|||||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||||
import { writeSessionOutput } from "./stream"
|
import { writeSessionOutput } from "./stream"
|
||||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||||
|
import { MARKDOWN_PATTERN_KINDS, MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
|
||||||
|
|
||||||
const KINDS = [
|
const KINDS = [
|
||||||
"markdown",
|
"markdown",
|
||||||
|
...MARKDOWN_PATTERN_KINDS,
|
||||||
"table",
|
"table",
|
||||||
"text",
|
"text",
|
||||||
"reasoning",
|
"reasoning",
|
||||||
@@ -51,53 +53,9 @@ function questionKind(value: string | undefined): QuestionKind | undefined {
|
|||||||
return QUESTIONS.find((item) => item === next)
|
return QUESTIONS.find((item) => item === next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_MARKDOWN = [
|
function markdownPattern(value: string): keyof typeof MARKDOWN_PATTERNS | undefined {
|
||||||
"# Direct Mode Demo",
|
if (value in MARKDOWN_PATTERNS) return value as keyof typeof MARKDOWN_PATTERNS
|
||||||
"",
|
}
|
||||||
"This is a realistic assistant response for direct-mode formatting checks.",
|
|
||||||
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
|
|
||||||
"",
|
|
||||||
"## Summary",
|
|
||||||
"",
|
|
||||||
"- Restored the final markdown flush so the last block is committed on idle.",
|
|
||||||
"- Switched markdown scrollback commits back to top-level block boundaries.",
|
|
||||||
"- Added footer-level regression coverage for split-footer rendering.",
|
|
||||||
"",
|
|
||||||
"## Status",
|
|
||||||
"",
|
|
||||||
"| Area | Before | After | Notes |",
|
|
||||||
"| --- | --- | --- | --- |",
|
|
||||||
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
|
|
||||||
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
|
|
||||||
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
|
|
||||||
"",
|
|
||||||
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
|
|
||||||
"",
|
|
||||||
"```ts",
|
|
||||||
"const result = { markdown: true, tables: 2, stable: true }",
|
|
||||||
"```",
|
|
||||||
"",
|
|
||||||
"## Files",
|
|
||||||
"",
|
|
||||||
"| File | Change |",
|
|
||||||
"| --- | --- |",
|
|
||||||
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
|
|
||||||
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
|
|
||||||
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
|
|
||||||
"",
|
|
||||||
"Next step: run `/fmt table` if you want a tighter table-only sample.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const SAMPLE_TABLE = [
|
|
||||||
"# Table Sample",
|
|
||||||
"",
|
|
||||||
"| Kind | Example | Notes |",
|
|
||||||
"| --- | --- | --- |",
|
|
||||||
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
|
|
||||||
"| Unicode | `漢字` | Wide characters should remain aligned |",
|
|
||||||
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
|
|
||||||
"| Status | done | Final row should still appear after idle |",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
type Ref = {
|
type Ref = {
|
||||||
msg: string
|
msg: string
|
||||||
@@ -1031,6 +989,12 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pattern = markdownPattern(kind)
|
||||||
|
if (pattern) {
|
||||||
|
await emitText(state, body || MARKDOWN_PATTERNS[pattern], signal)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (kind === "table") {
|
if (kind === "table") {
|
||||||
await emitText(state, body || SAMPLE_TABLE, signal)
|
await emitText(state, body || SAMPLE_TABLE, signal)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// 4. runs the prompt queue until the footer closes.
|
// 4. runs the prompt queue until the footer closes.
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { createRunDemo } from "./demo"
|
import type { createRunDemo } from "./demo"
|
||||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
|
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
|
||||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||||
@@ -135,6 +135,10 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
|||||||
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createDemo(input: Parameters<typeof createRunDemo>[0]) {
|
||||||
|
return (await import("./demo")).createRunDemo(input)
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveExitTitle(
|
async function resolveExitTitle(
|
||||||
ctx: BootContext,
|
ctx: BootContext,
|
||||||
input: RunRuntimeInput,
|
input: RunRuntimeInput,
|
||||||
@@ -425,7 +429,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
|
|
||||||
if (input.demo) {
|
if (input.demo) {
|
||||||
await ensureSession()
|
await ensureSession()
|
||||||
state.demo = createRunDemo({
|
state.demo = await createDemo({
|
||||||
footer,
|
footer,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
@@ -548,7 +552,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
state.history = []
|
state.history = []
|
||||||
includeFiles = true
|
includeFiles = true
|
||||||
state.demo = input.demo
|
state.demo = input.demo
|
||||||
? createRunDemo({
|
? await createDemo({
|
||||||
footer,
|
footer,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
|||||||
@@ -962,6 +962,8 @@ function getSyntaxRules(theme: Theme) {
|
|||||||
style: {
|
style: {
|
||||||
foreground: theme.markdownHeading,
|
foreground: theme.markdownHeading,
|
||||||
bold: true,
|
bold: true,
|
||||||
|
italic: true,
|
||||||
|
underline: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1175,6 +1177,7 @@ function getSyntaxRules(theme: Theme) {
|
|||||||
scope: ["markup.strikethrough"],
|
scope: ["markup.strikethrough"],
|
||||||
style: {
|
style: {
|
||||||
foreground: theme.textMuted,
|
foreground: theme.textMuted,
|
||||||
|
strikethrough: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
AssistantMessage,
|
||||||
|
Config,
|
||||||
|
Message,
|
||||||
|
Model,
|
||||||
|
Part,
|
||||||
|
Path,
|
||||||
|
Project,
|
||||||
|
Provider,
|
||||||
|
Session,
|
||||||
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
import type { EventSource } from "./context/sdk"
|
||||||
|
import { MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
|
||||||
|
|
||||||
|
const sessionID = "demo_tui_markdown"
|
||||||
|
const userMessageID = "demo_tui_user"
|
||||||
|
const assistantMessageID = "demo_tui_assistant"
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const markdown = [
|
||||||
|
"# Fullscreen TUI Markdown Demo",
|
||||||
|
"",
|
||||||
|
"This fake assistant response runs through the fullscreen session timeline without calling an LLM.",
|
||||||
|
"Use it to compare spacing, wrapping, code fence boundaries, table behavior, and inline markdown rendering.",
|
||||||
|
"",
|
||||||
|
"## Baseline",
|
||||||
|
"",
|
||||||
|
SAMPLE_MARKDOWN,
|
||||||
|
"",
|
||||||
|
"## Table Baseline",
|
||||||
|
"",
|
||||||
|
SAMPLE_TABLE,
|
||||||
|
"",
|
||||||
|
...Object.entries(MARKDOWN_PATTERNS).flatMap(([name, value]) => ["## " + name, "", value, ""]),
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
id: "demo",
|
||||||
|
providerID: "demo",
|
||||||
|
api: {
|
||||||
|
id: "demo",
|
||||||
|
url: "https://example.com/demo",
|
||||||
|
npm: "demo",
|
||||||
|
},
|
||||||
|
name: "Demo",
|
||||||
|
capabilities: {
|
||||||
|
temperature: false,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: false,
|
||||||
|
toolcall: true,
|
||||||
|
input: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: false,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: false,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
interleaved: true,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128_000,
|
||||||
|
output: 16_000,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
} satisfies Model
|
||||||
|
|
||||||
|
const provider = {
|
||||||
|
id: "demo",
|
||||||
|
name: "Demo",
|
||||||
|
source: "custom",
|
||||||
|
env: [],
|
||||||
|
options: {},
|
||||||
|
models: {
|
||||||
|
demo: model,
|
||||||
|
},
|
||||||
|
} satisfies Provider
|
||||||
|
|
||||||
|
const agent = {
|
||||||
|
name: "build",
|
||||||
|
description: "Demo agent",
|
||||||
|
mode: "primary",
|
||||||
|
native: true,
|
||||||
|
permission: [],
|
||||||
|
model: {
|
||||||
|
providerID: "demo",
|
||||||
|
modelID: "demo",
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
} satisfies Agent
|
||||||
|
|
||||||
|
function json(data: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTuiDemo(input: { directory: string }) {
|
||||||
|
const paths = {
|
||||||
|
home: process.env.HOME ?? input.directory,
|
||||||
|
state: input.directory,
|
||||||
|
config: input.directory,
|
||||||
|
worktree: input.directory,
|
||||||
|
directory: input.directory,
|
||||||
|
} satisfies Path
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
id: "demo_project",
|
||||||
|
worktree: input.directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "Markdown Demo",
|
||||||
|
time: {
|
||||||
|
created: now,
|
||||||
|
updated: now,
|
||||||
|
},
|
||||||
|
sandboxes: [],
|
||||||
|
} satisfies Project
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "markdown-demo",
|
||||||
|
projectID: project.id,
|
||||||
|
directory: input.directory,
|
||||||
|
title: "Markdown Rendering Demo",
|
||||||
|
agent: agent.name,
|
||||||
|
model: {
|
||||||
|
id: model.id,
|
||||||
|
providerID: provider.id,
|
||||||
|
},
|
||||||
|
version: "demo",
|
||||||
|
time: {
|
||||||
|
created: now,
|
||||||
|
updated: now + 2,
|
||||||
|
},
|
||||||
|
} satisfies Session
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: userMessageID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: {
|
||||||
|
created: now,
|
||||||
|
},
|
||||||
|
agent: agent.name,
|
||||||
|
model: {
|
||||||
|
providerID: provider.id,
|
||||||
|
modelID: model.id,
|
||||||
|
},
|
||||||
|
} satisfies Message
|
||||||
|
|
||||||
|
const assistant = {
|
||||||
|
id: assistantMessageID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: {
|
||||||
|
created: now + 1,
|
||||||
|
completed: now + 2,
|
||||||
|
},
|
||||||
|
parentID: userMessageID,
|
||||||
|
modelID: model.id,
|
||||||
|
providerID: provider.id,
|
||||||
|
mode: "demo",
|
||||||
|
agent: agent.name,
|
||||||
|
path: {
|
||||||
|
cwd: input.directory,
|
||||||
|
root: input.directory,
|
||||||
|
},
|
||||||
|
cost: 0,
|
||||||
|
tokens: {
|
||||||
|
input: 120,
|
||||||
|
output: 3_200,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies AssistantMessage
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: user,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "demo_tui_user_text",
|
||||||
|
sessionID,
|
||||||
|
messageID: userMessageID,
|
||||||
|
type: "text",
|
||||||
|
text: "Show me the fullscreen TUI markdown rendering stress cases.",
|
||||||
|
time: {
|
||||||
|
start: now,
|
||||||
|
end: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: assistant,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "demo_tui_assistant_text",
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantMessageID,
|
||||||
|
type: "text",
|
||||||
|
text: markdown,
|
||||||
|
time: {
|
||||||
|
start: now + 1,
|
||||||
|
end: now + 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies Array<{ info: Message; parts: Part[] }>
|
||||||
|
|
||||||
|
const fetch = (async (...args: Parameters<typeof globalThis.fetch>) => {
|
||||||
|
const request = new Request(args[0], args[1])
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const pathname = url.pathname
|
||||||
|
|
||||||
|
if (request.method === "GET" && pathname === "/path") return json(paths)
|
||||||
|
if (request.method === "GET" && pathname === "/project/current") return json(project)
|
||||||
|
if (request.method === "GET" && pathname === "/config/providers") {
|
||||||
|
return json({ providers: [provider], default: { build: "demo/demo" } })
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && pathname === "/provider") {
|
||||||
|
return json({ all: [provider], default: { build: "demo/demo" }, connected: [provider.id] })
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && pathname === "/experimental/console") {
|
||||||
|
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && pathname === "/agent") return json([agent])
|
||||||
|
if (request.method === "GET" && pathname === "/config") {
|
||||||
|
return json({ model: "demo/demo", default_agent: agent.name } satisfies Config)
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && pathname === "/session") return json([session])
|
||||||
|
if (request.method === "GET" && pathname === "/command") return json([])
|
||||||
|
if (request.method === "GET" && pathname === "/lsp") return json([])
|
||||||
|
if (request.method === "GET" && pathname === "/mcp") return json({})
|
||||||
|
if (request.method === "GET" && pathname === "/experimental/resource") return json({})
|
||||||
|
if (request.method === "GET" && pathname === "/formatter") return json([])
|
||||||
|
if (request.method === "GET" && pathname === "/session/status") return json({ [sessionID]: { type: "idle" } })
|
||||||
|
if (request.method === "GET" && pathname === "/provider/auth") return json({})
|
||||||
|
if (request.method === "GET" && pathname === "/vcs") return json({ branch: "demo", default_branch: "dev" })
|
||||||
|
if (request.method === "GET" && pathname === "/experimental/workspace") return json([])
|
||||||
|
if (request.method === "GET" && pathname === "/experimental/workspace/status") return json([])
|
||||||
|
if (request.method === "GET" && pathname === `/session/${sessionID}`) return json(session)
|
||||||
|
if (request.method === "GET" && pathname === `/session/${sessionID}/message`) return json(messages)
|
||||||
|
if (request.method === "GET" && pathname === `/session/${sessionID}/todo`) return json([])
|
||||||
|
if (request.method === "GET" && pathname === `/session/${sessionID}/diff`) return json([])
|
||||||
|
if (request.method === "GET" && pathname === `/session/${sessionID}/children`) return json([])
|
||||||
|
|
||||||
|
return json({ message: `Unhandled demo endpoint: ${request.method} ${pathname}` }, 404)
|
||||||
|
}) as typeof globalThis.fetch
|
||||||
|
|
||||||
|
const events = {
|
||||||
|
subscribe: async () => () => {},
|
||||||
|
} satisfies EventSource
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionID,
|
||||||
|
fetch,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,20 @@ import { useEvent } from "@tui/context/event"
|
|||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
import {
|
||||||
|
BoxRenderable,
|
||||||
|
ScrollBoxRenderable,
|
||||||
|
addDefaultParsers,
|
||||||
|
TextAttributes,
|
||||||
|
RGBA,
|
||||||
|
type MarkdownOptions,
|
||||||
|
type MarkdownRenderable,
|
||||||
|
CodeRenderable,
|
||||||
|
TextTableRenderable,
|
||||||
|
TextRenderable,
|
||||||
|
StyledText,
|
||||||
|
type TextChunk,
|
||||||
|
} from "@opentui/core"
|
||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
@@ -1525,15 +1538,180 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
|||||||
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
|
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const text = createMemo(() => props.part.text.trim())
|
||||||
|
const diffCache = new Map<string, TextChunk[]>()
|
||||||
|
const colorDiffChunks = (text: string) => {
|
||||||
|
const key = `${theme.diffAdded.toString()}:${theme.diffRemoved.toString()}:${text}`
|
||||||
|
const cached = diffCache.get(key)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
let line: "added" | "removed" | undefined
|
||||||
|
let start = true
|
||||||
|
const chunks = (text.match(/[^\n]+|\n/g) ?? [text]).map((part): TextChunk => {
|
||||||
|
if (start && part !== "\n") {
|
||||||
|
line = part.startsWith("+") ? "added" : part.startsWith("-") ? "removed" : undefined
|
||||||
|
start = false
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
__isChunk: true,
|
||||||
|
text: part,
|
||||||
|
...(line === "added" ? { fg: theme.diffAdded } : {}),
|
||||||
|
...(line === "removed" ? { fg: theme.diffRemoved } : {}),
|
||||||
|
} satisfies TextChunk
|
||||||
|
if (part === "\n") {
|
||||||
|
line = undefined
|
||||||
|
start = true
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
diffCache.set(key, chunks)
|
||||||
|
if (diffCache.size > 20) diffCache.delete(diffCache.keys().next().value!)
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
const renderBlockquoteBar = (chunks: TextChunk[]) => {
|
||||||
|
let lineStart = true
|
||||||
|
let spaces = 0
|
||||||
|
let replaced = false
|
||||||
|
let skipWhitespace = false
|
||||||
|
return chunks.flatMap((chunk) => {
|
||||||
|
const result: TextChunk[] = []
|
||||||
|
let next = ""
|
||||||
|
const flush = () => {
|
||||||
|
if (!next) return
|
||||||
|
result.push(next === chunk.text ? chunk : { ...chunk, text: next })
|
||||||
|
next = ""
|
||||||
|
}
|
||||||
|
for (const char of chunk.text) {
|
||||||
|
if (skipWhitespace && (char === " " || char === "\t")) {
|
||||||
|
skipWhitespace = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skipWhitespace = false
|
||||||
|
if (lineStart && !replaced && char === " " && spaces < 3) {
|
||||||
|
spaces++
|
||||||
|
next += char
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lineStart && !replaced && char === ">") {
|
||||||
|
flush()
|
||||||
|
result.push({ __isChunk: true, text: "│ ", fg: theme.textMuted, attributes: TextAttributes.NONE })
|
||||||
|
replaced = true
|
||||||
|
skipWhitespace = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next += char
|
||||||
|
if (char === "\n") {
|
||||||
|
lineStart = true
|
||||||
|
spaces = 0
|
||||||
|
replaced = false
|
||||||
|
skipWhitespace = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineStart = false
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const trimCodeIndent = (value: string) => {
|
||||||
|
const lines = value.split("\n")
|
||||||
|
const indents = lines.filter((line) => line.trim()).map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0)
|
||||||
|
const indent = Math.min(...indents)
|
||||||
|
if (!Number.isFinite(indent) || indent === 0) return value
|
||||||
|
return lines.map((line) => (line.trim() ? line.slice(indent) : line)).join("\n")
|
||||||
|
}
|
||||||
|
const padTableCells = (renderable: TextTableRenderable) => {
|
||||||
|
renderable.content = renderable.content.map((row) =>
|
||||||
|
row.map((cell) => {
|
||||||
|
const content = cell ?? []
|
||||||
|
return [{ __isChunk: true, text: " " }, ...content, { __isChunk: true, text: " " }] satisfies TextChunk[]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const configureMarkdown = (node: MarkdownRenderable | undefined) => {
|
||||||
|
if (!node) return
|
||||||
|
const renderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
|
||||||
|
const content = text()
|
||||||
|
const firstBlock = content.startsWith(token.raw.trimStart())
|
||||||
|
|
||||||
|
if (token.type === "hr") {
|
||||||
|
return new BoxRenderable(node.ctx, {
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
border: ["top"],
|
||||||
|
borderColor: theme.border,
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === "blockquote") {
|
||||||
|
const renderable = context.defaultRender()
|
||||||
|
if (renderable instanceof CodeRenderable) {
|
||||||
|
const code = renderable
|
||||||
|
const onChunks = code.onChunks
|
||||||
|
code.onChunks = (chunks, context) => {
|
||||||
|
const result = onChunks?.call(code, chunks, context)
|
||||||
|
if (result instanceof Promise) return result.then((next) => renderBlockquoteBar(next ?? chunks))
|
||||||
|
return renderBlockquoteBar(result ?? chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!firstBlock && renderable) {
|
||||||
|
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
|
||||||
|
}
|
||||||
|
return renderable
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsCodeTopGap = token.type === "code" && !firstBlock
|
||||||
|
if (token.type === "code" && /^[ \t]{4,}(```|~~~)/.test(token.raw)) {
|
||||||
|
token.text = trimCodeIndent(token.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === "table") {
|
||||||
|
const renderable = context.defaultRender()
|
||||||
|
if (renderable instanceof TextTableRenderable) padTableCells(renderable)
|
||||||
|
return renderable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === "code" && token.lang?.trim().toLowerCase() === "diff") {
|
||||||
|
const renderable = new TextRenderable(node.ctx, {
|
||||||
|
content: new StyledText(colorDiffChunks(token.text)),
|
||||||
|
width: "100%",
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
if (needsCodeTopGap) renderable.marginTop = 1
|
||||||
|
return renderable
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderable = context.defaultRender()
|
||||||
|
if (token.type === "heading" && token.depth === 1 && !firstBlock && renderable) {
|
||||||
|
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 2) : 2
|
||||||
|
}
|
||||||
|
if (needsCodeTopGap && renderable) {
|
||||||
|
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
|
||||||
|
}
|
||||||
|
return renderable
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTUI Solid constructs elements with only `{ id }`, so constructor-only
|
||||||
|
// MarkdownOptions need to be installed on the renderable directly.
|
||||||
|
const target = node as unknown as {
|
||||||
|
_internalBlockMode: "top-level"
|
||||||
|
_renderNode: typeof renderNode
|
||||||
|
}
|
||||||
|
target._internalBlockMode = "top-level"
|
||||||
|
target._renderNode = renderNode
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Show when={props.part.text.trim()}>
|
<Show when={text()}>
|
||||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||||
<markdown
|
<markdown
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
content={props.part.text.trim()}
|
ref={configureMarkdown}
|
||||||
|
tableOptions={{ style: "grid", widthMode: "content" }}
|
||||||
|
content={text()}
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
fg={theme.markdownText}
|
fg={theme.markdownText}
|
||||||
bg={theme.background}
|
bg={theme.background}
|
||||||
@@ -1545,7 +1723,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
|||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
content={props.part.text.trim()}
|
content={text()}
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
fg={theme.text}
|
fg={theme.text}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ export const TuiThreadCommand = cmd({
|
|||||||
.option("agent", {
|
.option("agent", {
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "agent to use",
|
describe: "agent to use",
|
||||||
|
})
|
||||||
|
.option("demo", {
|
||||||
|
type: "boolean",
|
||||||
|
describe: "open a fake fullscreen TUI session for renderer debugging",
|
||||||
}),
|
}),
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||||
@@ -130,7 +134,6 @@ export const TuiThreadCommand = cmd({
|
|||||||
// Resolve relative --project paths from PWD, then use the real cwd after
|
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||||
// chdir so the thread and worker share the same directory key.
|
// chdir so the thread and worker share the same directory key.
|
||||||
const next = resolveThreadDirectory(args.project)
|
const next = resolveThreadDirectory(args.project)
|
||||||
const file = await target()
|
|
||||||
try {
|
try {
|
||||||
process.chdir(next)
|
process.chdir(next)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -138,6 +141,32 @@ export const TuiThreadCommand = cmd({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cwd = Filesystem.resolve(process.cwd())
|
const cwd = Filesystem.resolve(process.cwd())
|
||||||
|
const config = TuiConfig.get()
|
||||||
|
config.catch(() => {})
|
||||||
|
|
||||||
|
if (args.demo) {
|
||||||
|
const { createTuiDemo } = await import("./demo")
|
||||||
|
const { tui } = await import("./app")
|
||||||
|
const demo = createTuiDemo({ directory: cwd })
|
||||||
|
await tui({
|
||||||
|
url: "http://opencode.demo",
|
||||||
|
config: await config,
|
||||||
|
directory: cwd,
|
||||||
|
fetch: demo.fetch,
|
||||||
|
events: demo.events,
|
||||||
|
args: {
|
||||||
|
continue: false,
|
||||||
|
sessionID: demo.sessionID,
|
||||||
|
agent: args.agent,
|
||||||
|
model: args.model,
|
||||||
|
prompt: await input(args.prompt),
|
||||||
|
fork: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await target()
|
||||||
const env = sanitizedProcessEnv({
|
const env = sanitizedProcessEnv({
|
||||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||||
@@ -187,8 +216,6 @@ export const TuiThreadCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = await input(args.prompt)
|
const prompt = await input(args.prompt)
|
||||||
const config = await TuiConfig.get()
|
|
||||||
|
|
||||||
const network = resolveNetworkOptionsNoConfig(args)
|
const network = resolveNetworkOptionsNoConfig(args)
|
||||||
const external =
|
const external =
|
||||||
process.argv.includes("--port") ||
|
process.argv.includes("--port") ||
|
||||||
@@ -236,7 +263,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
const server = await client.call("snapshot", undefined)
|
const server = await client.call("snapshot", undefined)
|
||||||
return [tui, server]
|
return [tui, server]
|
||||||
},
|
},
|
||||||
config,
|
config: await config,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch: transport.fetch,
|
||||||
events: transport.events,
|
events: transport.events,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { SessionProcessor } from "@/session/processor"
|
|||||||
import { SessionCompaction } from "@/session/compaction"
|
import { SessionCompaction } from "@/session/compaction"
|
||||||
import { SessionRevert } from "@/session/revert"
|
import { SessionRevert } from "@/session/revert"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
|
import { SessionTimeline } from "@/session/timeline"
|
||||||
import { SessionPrompt } from "@/session/prompt"
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
import { Instruction } from "@/session/instruction"
|
import { Instruction } from "@/session/instruction"
|
||||||
import { LLM } from "@/session/llm"
|
import { LLM } from "@/session/llm"
|
||||||
@@ -85,6 +86,7 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
SessionCompaction.defaultLayer,
|
SessionCompaction.defaultLayer,
|
||||||
SessionRevert.defaultLayer,
|
SessionRevert.defaultLayer,
|
||||||
SessionSummary.defaultLayer,
|
SessionSummary.defaultLayer,
|
||||||
|
SessionTimeline.defaultLayer,
|
||||||
SessionPrompt.defaultLayer,
|
SessionPrompt.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LLM.defaultLayer,
|
LLM.defaultLayer,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { SessionRevert } from "@/session/revert"
|
|||||||
import { SessionRunState } from "@/session/run-state"
|
import { SessionRunState } from "@/session/run-state"
|
||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
|
import { SessionTimeline } from "@/session/timeline"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
@@ -210,6 +211,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
|||||||
SessionRunState.defaultLayer,
|
SessionRunState.defaultLayer,
|
||||||
SessionStatus.defaultLayer,
|
SessionStatus.defaultLayer,
|
||||||
SessionSummary.defaultLayer,
|
SessionSummary.defaultLayer,
|
||||||
|
SessionTimeline.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
SyncEvent.defaultLayer,
|
SyncEvent.defaultLayer,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as EffectZod from "@opencode-ai/core/effect-zod"
|
|||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionID, MessageID, PartID } from "./schema"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { SessionRevert } from "./revert"
|
import { SessionTimeline } from "./timeline"
|
||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
@@ -114,7 +114,7 @@ export const layer = Layer.effect(
|
|||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const state = yield* SessionRunState.Service
|
const state = yield* SessionRunState.Service
|
||||||
const revert = yield* SessionRevert.Service
|
const timeline = yield* SessionTimeline.Service
|
||||||
const summary = yield* SessionSummary.Service
|
const summary = yield* SessionSummary.Service
|
||||||
const sys = yield* SystemPrompt.Service
|
const sys = yield* SystemPrompt.Service
|
||||||
const llm = yield* LLM.Service
|
const llm = yield* LLM.Service
|
||||||
@@ -747,9 +747,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
const { msg, part, cwd } = yield* Effect.gen(function* () {
|
const { msg, part, cwd } = yield* Effect.gen(function* () {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
if (session.revert) {
|
if (session.revert) yield* timeline.commitPending({ sessionID: session.id })
|
||||||
yield* revert.cleanup(session)
|
|
||||||
}
|
|
||||||
const agent = yield* agents.get(input.agent)
|
const agent = yield* agents.get(input.agent)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||||
@@ -1378,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
"SessionPrompt.prompt",
|
"SessionPrompt.prompt",
|
||||||
)(function* (input: PromptInput) {
|
)(function* (input: PromptInput) {
|
||||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
yield* revert.cleanup(session)
|
yield* timeline.commitPending({ sessionID: session.id })
|
||||||
const message = yield* createUserMessage(input)
|
const message = yield* createUserMessage(input)
|
||||||
yield* sessions.touch(input.sessionID)
|
yield* sessions.touch(input.sessionID)
|
||||||
|
|
||||||
@@ -1792,7 +1790,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionTimeline.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provide(Image.defaultLayer),
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Snapshot } from "../snapshot"
|
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import { SyncEvent } from "../sync"
|
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { zod } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics } from "@opencode-ai/core/schema"
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
|
||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionTimeline } from "./timeline"
|
||||||
import { SessionRunState } from "./run-state"
|
|
||||||
import { SessionSummary } from "./summary"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "session.revert" })
|
const log = Log.create({ service: "session.revert" })
|
||||||
|
|
||||||
@@ -18,6 +12,7 @@ export const RevertInput = Schema.Struct({
|
|||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
messageID: MessageID,
|
messageID: MessageID,
|
||||||
partID: Schema.optional(PartID),
|
partID: Schema.optional(PartID),
|
||||||
|
files: Schema.optional(RewindFilePolicy),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type RevertInput = Schema.Schema.Type<typeof RevertInput>
|
export type RevertInput = Schema.Schema.Type<typeof RevertInput>
|
||||||
|
|
||||||
@@ -32,133 +27,25 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessions = yield* Session.Service
|
const timeline = yield* SessionTimeline.Service
|
||||||
const snap = yield* Snapshot.Service
|
|
||||||
const storage = yield* Storage.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const summary = yield* SessionSummary.Service
|
|
||||||
const state = yield* SessionRunState.Service
|
|
||||||
const sync = yield* SyncEvent.Service
|
|
||||||
|
|
||||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||||
yield* state.assertNotBusy(input.sessionID)
|
return yield* timeline.rewind(input)
|
||||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
|
||||||
let lastUser: MessageV2.User | undefined
|
|
||||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
let rev: Session.Info["revert"]
|
|
||||||
const patches: Snapshot.Patch[] = []
|
|
||||||
for (const msg of all) {
|
|
||||||
if (msg.info.role === "user") lastUser = msg.info
|
|
||||||
const remaining = []
|
|
||||||
for (const part of msg.parts) {
|
|
||||||
if (rev) {
|
|
||||||
if (part.type === "patch") patches.push(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rev) {
|
|
||||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
|
||||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
|
||||||
rev = {
|
|
||||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
|
||||||
partID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remaining.push(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rev) return session
|
|
||||||
|
|
||||||
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
|
||||||
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
|
|
||||||
yield* snap.revert(patches)
|
|
||||||
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
|
|
||||||
const range = all.filter((msg) => msg.info.id >= rev.messageID)
|
|
||||||
const diffs = yield* summary.computeDiff({ messages: range })
|
|
||||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
|
||||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
|
||||||
yield* sessions.setRevert({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
revert: rev,
|
|
||||||
summary: {
|
|
||||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
|
||||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
|
||||||
files: diffs.length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
||||||
log.info("unreverting", input)
|
log.info("unreverting", input)
|
||||||
yield* state.assertNotBusy(input.sessionID)
|
return yield* timeline.restore(input)
|
||||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
|
||||||
if (!session.revert) return session
|
|
||||||
if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
|
|
||||||
yield* sessions.clearRevert(input.sessionID)
|
|
||||||
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
||||||
if (!session.revert) return
|
yield* timeline.commitPending({ sessionID: session.id })
|
||||||
const sessionID = session.id
|
|
||||||
const msgs = yield* sessions.messages({ sessionID })
|
|
||||||
const messageID = session.revert.messageID
|
|
||||||
const remove = [] as MessageV2.WithParts[]
|
|
||||||
let target: MessageV2.WithParts | undefined
|
|
||||||
for (const msg of msgs) {
|
|
||||||
if (msg.info.id < messageID) continue
|
|
||||||
if (msg.info.id > messageID) {
|
|
||||||
remove.push(msg)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (session.revert.partID) {
|
|
||||||
target = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remove.push(msg)
|
|
||||||
}
|
|
||||||
for (const msg of remove) {
|
|
||||||
yield* sync.run(MessageV2.Event.Removed, {
|
|
||||||
sessionID,
|
|
||||||
messageID: msg.info.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (session.revert.partID && target) {
|
|
||||||
const partID = session.revert.partID
|
|
||||||
const idx = target.parts.findIndex((part) => part.id === partID)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const removeParts = target.parts.slice(idx)
|
|
||||||
target.parts = target.parts.slice(0, idx)
|
|
||||||
for (const part of removeParts) {
|
|
||||||
yield* sync.run(MessageV2.Event.PartRemoved, {
|
|
||||||
sessionID,
|
|
||||||
messageID: target.info.id,
|
|
||||||
partID: part.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield* sessions.clearRevert(sessionID)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ revert, unrevert, cleanup })
|
return Service.of({ revert, unrevert, cleanup })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(SessionTimeline.defaultLayer)))
|
||||||
layer.pipe(
|
|
||||||
Layer.provide(SessionRunState.defaultLayer),
|
|
||||||
Layer.provide(Session.defaultLayer),
|
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
|
||||||
Layer.provide(Storage.defaultLayer),
|
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
|
||||||
Layer.provide(SyncEvent.defaultLayer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export * as SessionRevert from "./revert"
|
export * as SessionRevert from "./revert"
|
||||||
|
|||||||
@@ -33,3 +33,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export type PartID = Schema.Schema.Type<typeof PartID>
|
export type PartID = Schema.Schema.Type<typeof PartID>
|
||||||
|
|
||||||
|
export const RewindFilePolicy = Schema.Union([Schema.Literal("revert"), Schema.Literal("keep")])
|
||||||
|
export type RewindFilePolicy = Schema.Schema.Type<typeof RewindFilePolicy>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { InstanceState } from "@/effect/instance-state"
|
|||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { ProjectID } from "../project/schema"
|
import { ProjectID } from "../project/schema"
|
||||||
import { WorkspaceID } from "../control-plane/schema"
|
import { WorkspaceID } from "../control-plane/schema"
|
||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
@@ -166,6 +166,7 @@ const Time = Schema.Struct({
|
|||||||
const Revert = Schema.Struct({
|
const Revert = Schema.Struct({
|
||||||
messageID: MessageID,
|
messageID: MessageID,
|
||||||
partID: optionalOmitUndefined(PartID),
|
partID: optionalOmitUndefined(PartID),
|
||||||
|
files: optionalOmitUndefined(RewindFilePolicy),
|
||||||
snapshot: optionalOmitUndefined(Schema.String),
|
snapshot: optionalOmitUndefined(Schema.String),
|
||||||
diff: optionalOmitUndefined(Schema.String),
|
diff: optionalOmitUndefined(Schema.String),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { Storage } from "@/storage/storage"
|
||||||
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
|
import * as Session from "./session"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
|
||||||
|
import { SessionRunState } from "./run-state"
|
||||||
|
import { SessionSummary } from "./summary"
|
||||||
|
|
||||||
|
export const RewindInput = Schema.Struct({
|
||||||
|
sessionID: SessionID,
|
||||||
|
messageID: MessageID,
|
||||||
|
partID: Schema.optional(PartID),
|
||||||
|
files: Schema.optional(RewindFilePolicy),
|
||||||
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type RewindInput = Schema.Schema.Type<typeof RewindInput>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly rewind: (input: RewindInput) => Effect.Effect<Session.Info>
|
||||||
|
readonly restore: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
|
||||||
|
readonly commitPending: (input: { sessionID: SessionID }) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTimeline") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const snap = yield* Snapshot.Service
|
||||||
|
const storage = yield* Storage.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const summary = yield* SessionSummary.Service
|
||||||
|
const state = yield* SessionRunState.Service
|
||||||
|
|
||||||
|
const rewind = Effect.fn("SessionTimeline.rewind")(function* (input: RewindInput) {
|
||||||
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
|
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||||
|
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
|
const files = input.files ?? "revert"
|
||||||
|
let lastUser: MessageV2.User | undefined
|
||||||
|
let rev: Session.Info["revert"]
|
||||||
|
const patches: Snapshot.Patch[] = []
|
||||||
|
const range: MessageV2.WithParts[] = []
|
||||||
|
|
||||||
|
for (const msg of all) {
|
||||||
|
if (msg.info.role === "user") lastUser = msg.info
|
||||||
|
const remaining = []
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (rev) {
|
||||||
|
if (files === "revert" && part.type === "patch") patches.push(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||||
|
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||||
|
rev = {
|
||||||
|
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||||
|
partID,
|
||||||
|
...(files === "keep" && { files }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining.push(part)
|
||||||
|
}
|
||||||
|
if (rev) range.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rev) return session
|
||||||
|
|
||||||
|
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
|
||||||
|
if (files === "revert") {
|
||||||
|
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
||||||
|
yield* snap.revert(patches)
|
||||||
|
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
|
||||||
|
}
|
||||||
|
const diffs = yield* summary.computeDiff({ messages: range })
|
||||||
|
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||||
|
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||||
|
yield* sessions.setRevert({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
revert: rev,
|
||||||
|
summary: {
|
||||||
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
|
files: diffs.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const restore = Effect.fn("SessionTimeline.restore")(function* (input: { sessionID: SessionID }) {
|
||||||
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
|
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
|
if (!session.revert) return session
|
||||||
|
if (session.revert.files !== "keep" && session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
|
||||||
|
yield* sessions.clearRevert(input.sessionID)
|
||||||
|
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const commitPending = Effect.fn("SessionTimeline.commitPending")(function* (input: { sessionID: SessionID }) {
|
||||||
|
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
|
if (!session.revert) return
|
||||||
|
const sessionID = session.id
|
||||||
|
const msgs = yield* sessions.messages({ sessionID })
|
||||||
|
const messageID = session.revert.messageID
|
||||||
|
const remove = [] as MessageV2.WithParts[]
|
||||||
|
let target: MessageV2.WithParts | undefined
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (msg.info.id < messageID) continue
|
||||||
|
if (msg.info.id > messageID) {
|
||||||
|
remove.push(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (session.revert.partID) {
|
||||||
|
target = msg
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remove.push(msg)
|
||||||
|
}
|
||||||
|
for (const msg of remove) {
|
||||||
|
yield* sessions.removeMessage({
|
||||||
|
sessionID,
|
||||||
|
messageID: msg.info.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (session.revert.partID && target) {
|
||||||
|
const idx = target.parts.findIndex((part) => part.id === session.revert?.partID)
|
||||||
|
if (idx >= 0) {
|
||||||
|
for (const part of target.parts.slice(idx)) {
|
||||||
|
yield* sessions.removePart({
|
||||||
|
sessionID,
|
||||||
|
messageID: target.info.id,
|
||||||
|
partID: part.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* sessions.clearRevert(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ rewind, restore, commitPending })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
|
layer.pipe(
|
||||||
|
Layer.provide(SessionRunState.defaultLayer),
|
||||||
|
Layer.provide(Session.defaultLayer),
|
||||||
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
|
Layer.provide(Storage.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as SessionTimeline from "./timeline"
|
||||||
@@ -30,8 +30,8 @@ import { SessionSummary } from "../../src/session/summary"
|
|||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import { SessionProcessor } from "../../src/session/processor"
|
import { SessionProcessor } from "../../src/session/processor"
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { SessionRevert } from "../../src/session/revert"
|
|
||||||
import { SessionRunState } from "../../src/session/run-state"
|
import { SessionRunState } from "../../src/session/run-state"
|
||||||
|
import { SessionTimeline } from "../../src/session/timeline"
|
||||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
import { SessionStatus } from "../../src/session/status"
|
import { SessionStatus } from "../../src/session/status"
|
||||||
import { SessionV2 } from "../../src/v2/session"
|
import { SessionV2 } from "../../src/v2/session"
|
||||||
@@ -199,7 +199,7 @@ function makeHttp() {
|
|||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
TestLLMServer.layer,
|
TestLLMServer.layer,
|
||||||
SessionPrompt.layer.pipe(
|
SessionPrompt.layer.pipe(
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionTimeline.defaultLayer),
|
||||||
Layer.provide(Image.defaultLayer),
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(summary),
|
Layer.provide(summary),
|
||||||
Layer.provideMerge(run),
|
Layer.provideMerge(run),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
|
|||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { SessionRevert } from "../../src/session/revert"
|
import { SessionRevert } from "../../src/session/revert"
|
||||||
|
import { SessionTimeline } from "../../src/session/timeline"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
@@ -18,6 +19,7 @@ void Log.init({ print: false })
|
|||||||
const env = Layer.mergeAll(
|
const env = Layer.mergeAll(
|
||||||
Session.defaultLayer,
|
Session.defaultLayer,
|
||||||
SessionRevert.defaultLayer,
|
SessionRevert.defaultLayer,
|
||||||
|
SessionTimeline.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
)
|
)
|
||||||
@@ -96,6 +98,51 @@ const tokens = {
|
|||||||
cache: { read: 0, write: 0 },
|
cache: { read: 0, write: 0 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileTurn = Effect.fn("test.fileTurn")(function* (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
dir: string
|
||||||
|
file: string
|
||||||
|
content: string
|
||||||
|
}) {
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const snapshot = yield* Snapshot.Service
|
||||||
|
const userMsg = yield* user(input.sessionID)
|
||||||
|
yield* text(input.sessionID, userMsg.id, `${input.file}:${input.content}`)
|
||||||
|
const assistantMsg = yield* assistant(input.sessionID, userMsg.id, input.dir)
|
||||||
|
const before = yield* snapshot.track()
|
||||||
|
if (!before) throw new Error("expected snapshot")
|
||||||
|
yield* write(path.join(input.dir, input.file), input.content)
|
||||||
|
const after = yield* snapshot.track()
|
||||||
|
if (!after) throw new Error("expected snapshot")
|
||||||
|
const patch = yield* snapshot.patch(before)
|
||||||
|
yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: assistantMsg.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "step-start",
|
||||||
|
snapshot: before,
|
||||||
|
})
|
||||||
|
yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: assistantMsg.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "step-finish",
|
||||||
|
reason: "stop",
|
||||||
|
snapshot: after,
|
||||||
|
cost: 0,
|
||||||
|
tokens,
|
||||||
|
})
|
||||||
|
yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: assistantMsg.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "patch",
|
||||||
|
hash: patch.hash,
|
||||||
|
files: patch.files,
|
||||||
|
})
|
||||||
|
return { user: userMsg, assistant: assistantMsg }
|
||||||
|
})
|
||||||
|
|
||||||
describe("revert + compact workflow", () => {
|
describe("revert + compact workflow", () => {
|
||||||
it.live(
|
it.live(
|
||||||
"should properly handle compact command after revert",
|
"should properly handle compact command after revert",
|
||||||
@@ -636,4 +683,58 @@ describe("revert + compact workflow", () => {
|
|||||||
{ git: true },
|
{ git: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"timeline rewind can keep file changes while hiding future messages",
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const timeline = yield* SessionTimeline.Service
|
||||||
|
|
||||||
|
yield* write(path.join(dir, "a.txt"), "a0")
|
||||||
|
|
||||||
|
const info = yield* session.create({})
|
||||||
|
const sid = info.id
|
||||||
|
const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" })
|
||||||
|
|
||||||
|
const rewound = yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" })
|
||||||
|
expect(rewound.revert?.messageID).toBe(turn.user.id)
|
||||||
|
expect(rewound.revert?.files).toBe("keep")
|
||||||
|
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||||
|
|
||||||
|
yield* timeline.commitPending({ sessionID: rewound.id })
|
||||||
|
expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([])
|
||||||
|
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"timeline restore keeps files for keep-file rewinds",
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const timeline = yield* SessionTimeline.Service
|
||||||
|
|
||||||
|
yield* write(path.join(dir, "a.txt"), "a0")
|
||||||
|
|
||||||
|
const info = yield* session.create({})
|
||||||
|
const sid = info.id
|
||||||
|
const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" })
|
||||||
|
|
||||||
|
yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" })
|
||||||
|
const restored = yield* timeline.restore({ sessionID: sid })
|
||||||
|
expect(restored.revert).toBeUndefined()
|
||||||
|
expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([
|
||||||
|
turn.user.id,
|
||||||
|
turn.assistant.id,
|
||||||
|
])
|
||||||
|
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Schema } from "effect"
|
|||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { SessionRevert } from "../../src/session/revert"
|
import { SessionRevert } from "../../src/session/revert"
|
||||||
|
import { SessionTimeline } from "../../src/session/timeline"
|
||||||
import { SessionStatus } from "../../src/session/status"
|
import { SessionStatus } from "../../src/session/status"
|
||||||
import { SessionSummary } from "../../src/session/summary"
|
import { SessionSummary } from "../../src/session/summary"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
@@ -218,8 +219,8 @@ describe("Session input schemas", () => {
|
|||||||
describe("SessionRevert.RevertInput", () => {
|
describe("SessionRevert.RevertInput", () => {
|
||||||
const decode = decodeUnknown(SessionRevert.RevertInput)
|
const decode = decodeUnknown(SessionRevert.RevertInput)
|
||||||
|
|
||||||
test("messageID is required, partID is optional", () => {
|
test("messageID is required, partID and file policy are optional", () => {
|
||||||
const withPart = { sessionID, messageID, partID }
|
const withPart = { sessionID, messageID, partID, files: "keep" as const }
|
||||||
expect(decode(withPart)).toEqual(withPart)
|
expect(decode(withPart)).toEqual(withPart)
|
||||||
expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
|
expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
|
||||||
|
|
||||||
@@ -232,6 +233,20 @@ describe("SessionRevert.RevertInput", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("SessionTimeline.RewindInput", () => {
|
||||||
|
const decode = decodeUnknown(SessionTimeline.RewindInput)
|
||||||
|
|
||||||
|
test("accepts optional file policy", () => {
|
||||||
|
const keep = { sessionID, messageID, files: "keep" as const }
|
||||||
|
expect(decode(keep)).toEqual(keep)
|
||||||
|
expect(SessionTimeline.RewindInput.zod.parse(keep)).toEqual(keep)
|
||||||
|
|
||||||
|
const revert = { sessionID, messageID, partID, files: "revert" as const }
|
||||||
|
expect(decode(revert)).toEqual(revert)
|
||||||
|
expect(SessionTimeline.RewindInput.zod.parse(revert)).toEqual(revert)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("SessionSummary.DiffInput", () => {
|
describe("SessionSummary.DiffInput", () => {
|
||||||
const decode = decodeUnknown(SessionSummary.DiffInput)
|
const decode = decodeUnknown(SessionSummary.DiffInput)
|
||||||
|
|
||||||
|
|||||||
@@ -63,13 +63,14 @@ describe("Session schema", () => {
|
|||||||
revert: {
|
revert: {
|
||||||
messageID: MessageID.ascending(),
|
messageID: MessageID.ascending(),
|
||||||
partID: undefined,
|
partID: undefined,
|
||||||
|
files: undefined,
|
||||||
snapshot: undefined,
|
snapshot: undefined,
|
||||||
diff: undefined,
|
diff: undefined,
|
||||||
},
|
},
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
|
|
||||||
expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "diffs")).toBe(false)
|
expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "diffs")).toBe(false)
|
||||||
for (const key of ["partID", "snapshot", "diff"]) {
|
for (const key of ["partID", "files", "snapshot", "diff"]) {
|
||||||
expect(Object.hasOwn(encoded.revert as Record<string, unknown>, key)).toBe(false)
|
expect(Object.hasOwn(encoded.revert as Record<string, unknown>, key)).toBe(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import path from "path"
|
|||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { SessionRevert } from "../../src/session/revert"
|
|
||||||
import { SessionSummary } from "../../src/session/summary"
|
import { SessionSummary } from "../../src/session/summary"
|
||||||
|
import { SessionTimeline } from "../../src/session/timeline"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { provideTmpdirServer } from "../fixture/fixture"
|
import { provideTmpdirServer } from "../fixture/fixture"
|
||||||
@@ -150,7 +150,7 @@ function makeHttp() {
|
|||||||
TestLLMServer.layer,
|
TestLLMServer.layer,
|
||||||
SessionSummary.defaultLayer,
|
SessionSummary.defaultLayer,
|
||||||
SessionPrompt.layer.pipe(
|
SessionPrompt.layer.pipe(
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionTimeline.defaultLayer),
|
||||||
Layer.provide(Image.defaultLayer),
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provideMerge(run),
|
Layer.provideMerge(run),
|
||||||
|
|||||||
@@ -3844,6 +3844,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
workspace?: string
|
workspace?: string
|
||||||
messageID?: string
|
messageID?: string
|
||||||
partID?: string
|
partID?: string
|
||||||
|
files?: "revert" | "keep"
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
@@ -3857,6 +3858,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
{ in: "body", key: "messageID" },
|
{ in: "body", key: "messageID" },
|
||||||
{ in: "body", key: "partID" },
|
{ in: "body", key: "partID" },
|
||||||
|
{ in: "body", key: "files" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export type ClientOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
|
| EventTuiPromptAppend
|
||||||
|
| EventTuiCommandExecute
|
||||||
|
| EventTuiToastShow1
|
||||||
|
| EventTuiSessionSelect
|
||||||
|
| EventServerConnected
|
||||||
|
| EventGlobalDisposed
|
||||||
| EventServerInstanceDisposed
|
| EventServerInstanceDisposed
|
||||||
| EventFileEdited
|
| EventFileEdited
|
||||||
| EventFileWatcherUpdated
|
| EventFileWatcherUpdated
|
||||||
@@ -24,10 +30,6 @@ export type Event =
|
|||||||
| EventSessionStatus
|
| EventSessionStatus
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
| EventTuiPromptAppend
|
|
||||||
| EventTuiCommandExecute
|
|
||||||
| EventTuiToastShow1
|
|
||||||
| EventTuiSessionSelect
|
|
||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
@@ -75,8 +77,6 @@ export type Event =
|
|||||||
| EventSessionNextCompactionStarted
|
| EventSessionNextCompactionStarted
|
||||||
| EventSessionNextCompactionDelta
|
| EventSessionNextCompactionDelta
|
||||||
| EventSessionNextCompactionEnded
|
| EventSessionNextCompactionEnded
|
||||||
| EventServerConnected
|
|
||||||
| EventGlobalDisposed
|
|
||||||
|
|
||||||
export type OAuth = {
|
export type OAuth = {
|
||||||
type: "oauth"
|
type: "oauth"
|
||||||
@@ -103,6 +103,61 @@ export type WellKnownAuth = {
|
|||||||
|
|
||||||
export type Auth = OAuth | ApiAuth | WellKnownAuth
|
export type Auth = OAuth | ApiAuth | WellKnownAuth
|
||||||
|
|
||||||
|
export type EventTuiPromptAppend = {
|
||||||
|
id: string
|
||||||
|
type: "tui.prompt.append"
|
||||||
|
properties: {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventTuiCommandExecute = {
|
||||||
|
id: string
|
||||||
|
type: "tui.command.execute"
|
||||||
|
properties: {
|
||||||
|
command:
|
||||||
|
| "session.list"
|
||||||
|
| "session.new"
|
||||||
|
| "session.share"
|
||||||
|
| "session.interrupt"
|
||||||
|
| "session.compact"
|
||||||
|
| "session.page.up"
|
||||||
|
| "session.page.down"
|
||||||
|
| "session.line.up"
|
||||||
|
| "session.line.down"
|
||||||
|
| "session.half.page.up"
|
||||||
|
| "session.half.page.down"
|
||||||
|
| "session.first"
|
||||||
|
| "session.last"
|
||||||
|
| "prompt.clear"
|
||||||
|
| "prompt.submit"
|
||||||
|
| "agent.cycle"
|
||||||
|
| string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventTuiToastShow = {
|
||||||
|
id: string
|
||||||
|
type: "tui.toast.show"
|
||||||
|
properties: {
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
variant: "info" | "success" | "warning" | "error"
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventTuiSessionSelect = {
|
||||||
|
id: string
|
||||||
|
type: "tui.session.select"
|
||||||
|
properties: {
|
||||||
|
/**
|
||||||
|
* Session ID to navigate to
|
||||||
|
*/
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type PermissionRequest = {
|
export type PermissionRequest = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -280,61 +335,6 @@ export type SessionStatus =
|
|||||||
type: "busy"
|
type: "busy"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventTuiPromptAppend = {
|
|
||||||
id: string
|
|
||||||
type: "tui.prompt.append"
|
|
||||||
properties: {
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventTuiCommandExecute = {
|
|
||||||
id: string
|
|
||||||
type: "tui.command.execute"
|
|
||||||
properties: {
|
|
||||||
command:
|
|
||||||
| "session.list"
|
|
||||||
| "session.new"
|
|
||||||
| "session.share"
|
|
||||||
| "session.interrupt"
|
|
||||||
| "session.compact"
|
|
||||||
| "session.page.up"
|
|
||||||
| "session.page.down"
|
|
||||||
| "session.line.up"
|
|
||||||
| "session.line.down"
|
|
||||||
| "session.half.page.up"
|
|
||||||
| "session.half.page.down"
|
|
||||||
| "session.first"
|
|
||||||
| "session.last"
|
|
||||||
| "prompt.clear"
|
|
||||||
| "prompt.submit"
|
|
||||||
| "agent.cycle"
|
|
||||||
| string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventTuiToastShow = {
|
|
||||||
id: string
|
|
||||||
type: "tui.toast.show"
|
|
||||||
properties: {
|
|
||||||
title?: string
|
|
||||||
message: string
|
|
||||||
variant: "info" | "success" | "warning" | "error"
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventTuiSessionSelect = {
|
|
||||||
id: string
|
|
||||||
type: "tui.session.select"
|
|
||||||
properties: {
|
|
||||||
/**
|
|
||||||
* Session ID to navigate to
|
|
||||||
*/
|
|
||||||
sessionID: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -762,6 +762,7 @@ export type Session = {
|
|||||||
revert?: {
|
revert?: {
|
||||||
messageID: string
|
messageID: string
|
||||||
partID?: string
|
partID?: string
|
||||||
|
files?: "revert" | "keep"
|
||||||
snapshot?: string
|
snapshot?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
}
|
}
|
||||||
@@ -778,6 +779,12 @@ export type GlobalEvent = {
|
|||||||
project?: string
|
project?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
payload:
|
payload:
|
||||||
|
| EventTuiPromptAppend
|
||||||
|
| EventTuiCommandExecute
|
||||||
|
| EventTuiToastShow
|
||||||
|
| EventTuiSessionSelect
|
||||||
|
| EventServerConnected
|
||||||
|
| EventGlobalDisposed
|
||||||
| EventServerInstanceDisposed
|
| EventServerInstanceDisposed
|
||||||
| EventFileEdited
|
| EventFileEdited
|
||||||
| EventFileWatcherUpdated
|
| EventFileWatcherUpdated
|
||||||
@@ -797,10 +804,6 @@ export type GlobalEvent = {
|
|||||||
| EventSessionStatus
|
| EventSessionStatus
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
| EventTuiPromptAppend
|
|
||||||
| EventTuiCommandExecute
|
|
||||||
| EventTuiToastShow
|
|
||||||
| EventTuiSessionSelect
|
|
||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
@@ -848,8 +851,6 @@ export type GlobalEvent = {
|
|||||||
| EventSessionNextCompactionStarted
|
| EventSessionNextCompactionStarted
|
||||||
| EventSessionNextCompactionDelta
|
| EventSessionNextCompactionDelta
|
||||||
| EventSessionNextCompactionEnded
|
| EventSessionNextCompactionEnded
|
||||||
| EventServerConnected
|
|
||||||
| EventGlobalDisposed
|
|
||||||
| SyncEventMessageUpdated
|
| SyncEventMessageUpdated
|
||||||
| SyncEventMessageRemoved
|
| SyncEventMessageRemoved
|
||||||
| SyncEventMessagePartUpdated
|
| SyncEventMessagePartUpdated
|
||||||
@@ -1450,6 +1451,7 @@ export type GlobalSession = {
|
|||||||
revert?: {
|
revert?: {
|
||||||
messageID: string
|
messageID: string
|
||||||
partID?: string
|
partID?: string
|
||||||
|
files?: "revert" | "keep"
|
||||||
snapshot?: string
|
snapshot?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
}
|
}
|
||||||
@@ -1913,6 +1915,7 @@ export type SyncEventSessionUpdated = {
|
|||||||
revert?: {
|
revert?: {
|
||||||
messageID: string
|
messageID: string
|
||||||
partID?: string
|
partID?: string
|
||||||
|
files?: "revert" | "keep"
|
||||||
snapshot?: string
|
snapshot?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
} | null
|
} | null
|
||||||
@@ -2330,6 +2333,22 @@ export type SyncEventSessionNextCompactionEnded = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventServerConnected = {
|
||||||
|
id: string
|
||||||
|
type: "server.connected"
|
||||||
|
properties: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventGlobalDisposed = {
|
||||||
|
id: string
|
||||||
|
type: "global.disposed"
|
||||||
|
properties: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventServerInstanceDisposed = {
|
export type EventServerInstanceDisposed = {
|
||||||
id: string
|
id: string
|
||||||
type: "server.instance.disposed"
|
type: "server.instance.disposed"
|
||||||
@@ -3044,22 +3063,6 @@ export type EventSessionNextCompactionEnded = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventServerConnected = {
|
|
||||||
id: string
|
|
||||||
type: "server.connected"
|
|
||||||
properties: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventGlobalDisposed = {
|
|
||||||
id: string
|
|
||||||
type: "global.disposed"
|
|
||||||
properties: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionInfo = {
|
export type SessionInfo = {
|
||||||
id: string
|
id: string
|
||||||
parentID?: string
|
parentID?: string
|
||||||
@@ -5935,6 +5938,7 @@ export type SessionRevertData = {
|
|||||||
body?: {
|
body?: {
|
||||||
messageID: string
|
messageID: string
|
||||||
partID?: string
|
partID?: string
|
||||||
|
files?: "revert" | "keep"
|
||||||
}
|
}
|
||||||
path: {
|
path: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
|||||||
Reference in New Issue
Block a user