diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 45e2a50118..7b10316bbf 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -19,36 +19,36 @@ altimate --agent analyst ## Subcommands -| Command | Description | -|---------|------------| -| `run` | Run a prompt non-interactively | -| `serve` | Start the HTTP API server | -| `web` | Start the web UI | -| `agent` | Agent management | -| `auth` | Authentication | -| `mcp` | Model Context Protocol tools | -| `acp` | Agent Communication Protocol | -| `models` | List available models | -| `stats` | Usage statistics | -| `export` | Export session data | -| `import` | Import session data | -| `session` | Session management | -| `trace` | List and view session traces | -| `github` | GitHub integration | -| `pr` | Pull request tools | -| `upgrade` | Upgrade to latest version | -| `uninstall` | Uninstall altimate | +| Command | Description | +| ----------- | ------------------------------ | +| `run` | Run a prompt non-interactively | +| `serve` | Start the HTTP API server | +| `web` | Start the web UI | +| `agent` | Agent management | +| `auth` | Authentication | +| `mcp` | Model Context Protocol tools | +| `acp` | Agent Communication Protocol | +| `models` | List available models | +| `stats` | Usage statistics | +| `export` | Export session data | +| `import` | Import session data | +| `session` | Session management | +| `trace` | List and view session traces | +| `github` | GitHub integration | +| `pr` | Pull request tools | +| `upgrade` | Upgrade to latest version | +| `uninstall` | Uninstall altimate | ## Global Flags -| Flag | Description | -|------|------------| -| `--model ` | Override the default model | -| `--agent ` | Start with a specific agent | -| `--print-logs` | Print logs to stderr | -| `--log-level ` | Set log level: `DEBUG`, `INFO`, `WARN`, `ERROR` | -| `--help`, `-h` | Show help | -| `--version`, `-v` | Show version | +| Flag | Description | +| -------------------------- | ----------------------------------------------- | +| `--model ` | Override the default model | +| `--agent ` | Start with a specific agent | +| `--print-logs` | Print logs to stderr | +| `--log-level ` | Set log level: `DEBUG`, `INFO`, `WARN`, `ERROR` | +| `--help`, `-h` | Show help | +| `--version`, `-v` | Show version | ## Environment Variables @@ -56,45 +56,70 @@ Configuration can be controlled via environment variables: ### Core Configuration -| Variable | Description | -|----------|------------| -| `ALTIMATE_CLI_CONFIG` | Path to custom config file | -| `ALTIMATE_CLI_CONFIG_DIR` | Custom config directory | +| Variable | Description | +| ----------------------------- | ---------------------------- | +| `ALTIMATE_CLI_CONFIG` | Path to custom config file | +| `ALTIMATE_CLI_CONFIG_DIR` | Custom config directory | | `ALTIMATE_CLI_CONFIG_CONTENT` | Inline config as JSON string | -| `ALTIMATE_CLI_GIT_BASH_PATH` | Path to Git Bash (Windows) | +| `ALTIMATE_CLI_GIT_BASH_PATH` | Path to Git Bash (Windows) | ### Feature Toggles -| Variable | Description | -|----------|------------| -| `ALTIMATE_CLI_DISABLE_AUTOUPDATE` | Disable automatic updates | -| `ALTIMATE_CLI_DISABLE_LSP_DOWNLOAD` | Don't auto-download LSP servers | -| `ALTIMATE_CLI_DISABLE_AUTOCOMPACT` | Disable automatic context compaction | -| `ALTIMATE_CLI_DISABLE_DEFAULT_PLUGINS` | Skip loading default plugins | -| `ALTIMATE_CLI_DISABLE_EXTERNAL_SKILLS` | Disable external skill discovery | -| `ALTIMATE_CLI_DISABLE_PROJECT_CONFIG` | Ignore project-level config files | -| `ALTIMATE_CLI_DISABLE_TERMINAL_TITLE` | Don't set terminal title | -| `ALTIMATE_CLI_DISABLE_PRUNE` | Disable database pruning | -| `ALTIMATE_CLI_DISABLE_MODELS_FETCH` | Don't fetch models from models.dev | +| Variable | Description | +| -------------------------------------- | ------------------------------------ | +| `ALTIMATE_CLI_DISABLE_AUTOUPDATE` | Disable automatic updates | +| `ALTIMATE_CLI_DISABLE_LSP_DOWNLOAD` | Don't auto-download LSP servers | +| `ALTIMATE_CLI_DISABLE_AUTOCOMPACT` | Disable automatic context compaction | +| `ALTIMATE_CLI_DISABLE_DEFAULT_PLUGINS` | Skip loading default plugins | +| `ALTIMATE_CLI_DISABLE_EXTERNAL_SKILLS` | Disable external skill discovery | +| `ALTIMATE_CLI_DISABLE_PROJECT_CONFIG` | Ignore project-level config files | +| `ALTIMATE_CLI_DISABLE_TERMINAL_TITLE` | Don't set terminal title | +| `ALTIMATE_CLI_DISABLE_PRUNE` | Disable database pruning | +| `ALTIMATE_CLI_DISABLE_MODELS_FETCH` | Don't fetch models from models.dev | ### Server & Security -| Variable | Description | -|----------|------------| +| Variable | Description | +| ------------------------------ | ------------------------------- | | `ALTIMATE_CLI_SERVER_USERNAME` | Server HTTP basic auth username | | `ALTIMATE_CLI_SERVER_PASSWORD` | Server HTTP basic auth password | -| `ALTIMATE_CLI_PERMISSION` | Permission config as JSON | +| `ALTIMATE_CLI_PERMISSION` | Permission config as JSON | ### Experimental -| Variable | Description | -|----------|------------| -| `ALTIMATE_CLI_EXPERIMENTAL` | Enable all experimental features | -| `ALTIMATE_CLI_EXPERIMENTAL_FILEWATCHER` | Enable file watcher | -| `ALTIMATE_CLI_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | Custom bash timeout (ms) | -| `ALTIMATE_CLI_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | Max output tokens | -| `ALTIMATE_CLI_EXPERIMENTAL_PLAN_MODE` | Enable plan mode | -| `ALTIMATE_CLI_ENABLE_EXA` | Enable Exa web search | +| Variable | Description | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ALTIMATE_CLI_EXPERIMENTAL` | Enable all experimental features | +| `ALTIMATE_CLI_EXPERIMENTAL_FILEWATCHER` | Enable file watcher | +| `ALTIMATE_CLI_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | Custom bash timeout (ms) | +| `ALTIMATE_CLI_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | Max output tokens | +| `ALTIMATE_CLI_EXPERIMENTAL_PLAN_MODE` | Enable plan mode | +| `ALTIMATE_CLI_ENABLE_EXA` | Enable Exa web search | +| `ALTIMATE_CALM_MODE` | Enables all streaming optimizations: smooth rendering, line-at-a-time buffering, and 100-column width cap. Recommended for a Claude Code-like experience. Equivalent to setting `ALTIMATE_SMOOTH_STREAMING=true ALTIMATE_LINE_STREAMING=true ALTIMATE_CONTENT_MAX_WIDTH=100`. | +| `ALTIMATE_SMOOTH_STREAMING` | Uses lightweight `` rendering during LLM streaming, then swaps to rich markdown after completion. Reduces text jumps and scroll jitter. Included in `ALTIMATE_CALM_MODE`. | +| `ALTIMATE_LINE_STREAMING` | Buffers LLM output and reveals one complete line at a time (on `\n`). Gives a calm, steady flow. Remaining text flushes on message completion or abort. Included in `ALTIMATE_CALM_MODE`. | +| `ALTIMATE_CONTENT_MAX_WIDTH` | Cap text content width in columns (e.g. `100`). Improves readability on wide screens. Automatically disabled on small terminals. Set to `100` by `ALTIMATE_CALM_MODE`. | + +#### Calm Mode Quick Start + +For a Claude Code-like streaming experience, add to your shell profile: + +```bash +export ALTIMATE_CALM_MODE=true +``` + +Or use individual flags for fine-grained control: + +```bash +# Smooth rendering only (no line buffering) +export ALTIMATE_SMOOTH_STREAMING=true + +# Line buffering only (no rendering changes) +export ALTIMATE_LINE_STREAMING=true + +# Custom width cap (e.g., 80 columns) +export ALTIMATE_CONTENT_MAX_WIDTH=80 +``` ## Non-interactive Usage diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 2403a4e938..b13453d366 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -2,6 +2,9 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup, onMount } from "solid-js" +// altimate_change start - smooth streaming +import { Flag } from "@/flag/flag" +// altimate_change end export type EventSource = { on: (handler: (event: Event) => void) => () => void @@ -48,6 +51,44 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ queue = [] timer = undefined last = Date.now() + + // altimate_change start - smooth streaming: pre-merge delta events + // When enabled, merge consecutive delta events for the same part+field + // to reduce store updates from N-per-part to 1-per-part per flush cycle. + if (Flag.ALTIMATE_SMOOTH_STREAMING) { + const merged: Event[] = [] + const deltaMap = new Map() + for (const event of events) { + if (event.type === "message.part.delta") { + const props = event.properties as { messageID: string; partID: string; field: string; delta: string } + const key = `${props.messageID}:${props.partID}:${props.field}` + const existing = deltaMap.get(key) + if (existing !== undefined) { + const prev = merged[existing] as typeof event + merged[existing] = { + ...prev, + properties: { + ...prev.properties, + delta: (prev.properties as typeof props).delta + props.delta, + }, + } as Event + continue + } + deltaMap.set(key, merged.length) + } else { + deltaMap.clear() + } + merged.push(event) + } + batch(() => { + for (const event of merged) { + emitter.emit(event.type, event) + } + }) + return + } + // altimate_change end + // Batch all event emissions so all store updates result in a single render batch(() => { for (const event of events) { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e22a0f3d09..2d3710faad 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -116,6 +116,43 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("workspaceList", reconcile(result.data)) } + // altimate_change start - line streaming: buffer deltas, flush only on \n or message completion + const lineBuffer = new Map() + + function flushLineBuffer(messageID: string, partID: string, field: string, forceAll: boolean) { + const key = `${messageID}:${partID}:${field}` + const buffer = lineBuffer.get(key) + if (!buffer) return + let textToFlush: string + if (forceAll) { + textToFlush = buffer + lineBuffer.delete(key) + } else { + const lastNewline = buffer.lastIndexOf("\n") + if (lastNewline === -1) return + textToFlush = buffer.slice(0, lastNewline + 1) + const remainder = buffer.slice(lastNewline + 1) + if (remainder) lineBuffer.set(key, remainder) + else lineBuffer.delete(key) + } + if (!textToFlush) return + const parts = store.part[messageID] + if (!parts) return + const result = Binary.search(parts, partID, (p) => p.id) + if (!result.found) return + const existing = parts[result.index][field as keyof (typeof parts)[number]] as string | undefined + setStore("part", messageID, result.index, field as any, ((existing ?? "") + textToFlush) as any) + } + + function flushAllBuffersForMessage(messageID: string) { + for (const [key] of lineBuffer) { + if (!key.startsWith(messageID + ":")) continue + const [, partID, field] = key.split(":") + flushLineBuffer(messageID, partID, field, true) + } + } + // altimate_change end + sdk.event.listen((e) => { const event = e.details switch (event.type) { @@ -254,6 +291,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { + // altimate_change start - line streaming: flush remaining buffer when message completes + if ( + Flag.ALTIMATE_LINE_STREAMING && + "completed" in event.properties.info.time && + event.properties.info.time.completed + ) { + flushAllBuffersForMessage(event.properties.info.id) + } + // altimate_change end const messages = store.message[event.properties.info.sessionID] if (!messages) { setStore("message", event.properties.info.sessionID, [event.properties.info]) @@ -293,6 +339,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.removed": { + // altimate_change start - line streaming: clean up buffers for removed/aborted messages + if (Flag.ALTIMATE_LINE_STREAMING) { + flushAllBuffersForMessage(event.properties.messageID) + } + // altimate_change end const messages = store.message[event.properties.sessionID] const result = Binary.search(messages, event.properties.messageID, (m) => m.id) if (result.found) { @@ -307,6 +358,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { + // altimate_change start - line streaming: discard buffered text when part is + // authoritatively set by the server (via reconcile). Without this, the buffer + // would append stale text on top of the server's complete content, duplicating + // the trailing partial line. + if (Flag.ALTIMATE_LINE_STREAMING) { + const { messageID, id: partID } = event.properties.part + for (const key of lineBuffer.keys()) { + if (key.startsWith(`${messageID}:${partID}:`)) lineBuffer.delete(key) + } + } + // altimate_change end const parts = store.part[event.properties.part.messageID] if (!parts) { setStore("part", event.properties.part.messageID, [event.properties.part]) @@ -332,20 +394,55 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (!parts) break const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (!result.found) break - setStore( - "part", - event.properties.messageID, - produce((draft) => { - const part = draft[result.index] - const field = event.properties.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + event.properties.delta - }), - ) + // altimate_change start - line streaming: buffer deltas, flush only on \n + // Note: when line streaming is enabled (including via calm mode), this branch + // handles all delta processing and breaks — the smooth streaming branch below + // is not reached. This is intentional: flushLineBuffer already does direct + // store path updates, so the produce() bypass is not needed. + if (Flag.ALTIMATE_LINE_STREAMING) { + const { messageID, partID, field, delta } = event.properties + const key = `${messageID}:${partID}:${field}` + lineBuffer.set(key, (lineBuffer.get(key) ?? "") + delta) + flushLineBuffer(messageID, partID, field, false) + break + } + // altimate_change end + // altimate_change start - smooth streaming: direct path update avoids produce() proxy overhead + if (Flag.ALTIMATE_SMOOTH_STREAMING) { + const field = event.properties.field as keyof (typeof parts)[number] + const existing = parts[result.index][field] as string | undefined + setStore( + "part", + event.properties.messageID, + result.index, + field as any, + ((existing ?? "") + event.properties.delta) as any, + ) + } else { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + const part = draft[result.index] + const field = event.properties.field as keyof typeof part + const existing = part[field] as string | undefined + ;(part[field] as string) = (existing ?? "") + event.properties.delta + }), + ) + } + // altimate_change end break } case "message.part.removed": { + // altimate_change start - line streaming: discard buffers for removed parts + if (Flag.ALTIMATE_LINE_STREAMING) { + const { messageID, partID } = event.properties + for (const key of lineBuffer.keys()) { + if (key.startsWith(`${messageID}:${partID}:`)) lineBuffer.delete(key) + } + } + // altimate_change end const parts = store.part[event.properties.messageID] const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (result.found) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f242f06aac..2564660dea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -313,12 +313,17 @@ export function Session() { dialog.clear() } + // altimate_change start - smooth streaming: reduce scroll lag function toBottom() { - setTimeout(() => { - if (!scroll || scroll.isDestroyed) return - scroll.scrollTo(scroll.scrollHeight) - }, 50) + setTimeout( + () => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollTo(scroll.scrollHeight) + }, + Flag.ALTIMATE_SMOOTH_STREAMING ? 0 : 50, + ) } + // altimate_change end const local = useLocal() @@ -1456,15 +1461,43 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { theme, syntax } = useTheme() + // altimate_change start - smooth streaming: memoize trim + const trimmed = createMemo(() => props.part.text.trim()) + // altimate_change end + // altimate_change start - smooth streaming: use during streaming to avoid layout jumps + const isStreaming = createMemo(() => Flag.ALTIMATE_SMOOTH_STREAMING && !props.message.time.completed) + // altimate_change end + // altimate_change start - calm mode: cap content width for readability, respect small screens + const cappedWidth = createMemo(() => { + const cap = Flag.ALTIMATE_CONTENT_MAX_WIDTH + if (!cap) return undefined + const available = ctx.width + // +3 accounts for paddingLeft on this box + const desired = cap + 3 + // On small screens, don't constrain — let it use full available width + return available <= desired ? undefined : desired + }) + // altimate_change end return ( - - + + + + + @@ -1472,9 +1505,9 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 19fdd20852..a94b2cc63e 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -86,6 +86,18 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") + // altimate_change start - calm mode: combines smooth streaming + line buffering + width cap + // Single flag enables all three optimizations. Individual flags still work for fine-grained control. + export const ALTIMATE_CALM_MODE = altTruthy("ALTIMATE_CALM_MODE", "OPENCODE_CALM_MODE") + export const ALTIMATE_SMOOTH_STREAMING = + ALTIMATE_CALM_MODE || altTruthy("ALTIMATE_SMOOTH_STREAMING", "OPENCODE_SMOOTH_STREAMING") + export const ALTIMATE_LINE_STREAMING = + ALTIMATE_CALM_MODE || altTruthy("ALTIMATE_LINE_STREAMING", "OPENCODE_LINE_STREAMING") + export const ALTIMATE_CONTENT_MAX_WIDTH = + number("ALTIMATE_CONTENT_MAX_WIDTH") ?? + number("OPENCODE_CONTENT_MAX_WIDTH") ?? + (ALTIMATE_CALM_MODE ? 100 : undefined) + // altimate_change end export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB") diff --git a/packages/opencode/test/cli/tui/calm-mode.test.ts b/packages/opencode/test/cli/tui/calm-mode.test.ts new file mode 100644 index 0000000000..7f7fa79583 --- /dev/null +++ b/packages/opencode/test/cli/tui/calm-mode.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, test, beforeEach } from "bun:test" + +/** + * Tests for the calm mode streaming optimizations: + * 1. Delta event merging (sdk.tsx flush logic) + * 2. Line buffering (sync.tsx line buffer logic) + * 3. Flag composition (flag.ts calm mode) + * + * These test the core algorithms extracted from the TUI components, + * since the actual Solid/OpenTUI components require a full render context. + */ + +// ─── Delta Event Merging ──────────────────────────────────────────────────── +// Extracted from sdk.tsx flush() — merges consecutive delta events for the +// same messageID:partID:field into a single event per flush cycle. + +type DeltaEvent = { + type: "message.part.delta" + properties: { messageID: string; partID: string; field: string; delta: string } +} + +type OtherEvent = { + type: string + properties: Record +} + +type Event = DeltaEvent | OtherEvent + +function mergeDeltaEvents(events: Event[]): Event[] { + const merged: Event[] = [] + const deltaMap = new Map() + for (const event of events) { + if (event.type === "message.part.delta") { + const props = event.properties as DeltaEvent["properties"] + const key = `${props.messageID}:${props.partID}:${props.field}` + const existing = deltaMap.get(key) + if (existing !== undefined) { + const prev = merged[existing] as DeltaEvent + merged[existing] = { + ...prev, + properties: { + ...prev.properties, + delta: prev.properties.delta + props.delta, + }, + } + continue + } + deltaMap.set(key, merged.length) + } else { + deltaMap.clear() + } + merged.push(event) + } + return merged +} + +describe("delta event merging", () => { + test("merges consecutive deltas for the same part+field", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "Hello" } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: " world" } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "!" } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(1) + expect((merged[0] as DeltaEvent).properties.delta).toBe("Hello world!") + }) + + test("keeps deltas for different parts separate", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "A" } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p2", field: "text", delta: "B" } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(2) + expect((merged[0] as DeltaEvent).properties.delta).toBe("A") + expect((merged[1] as DeltaEvent).properties.delta).toBe("B") + }) + + test("keeps deltas for different fields separate", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "A" } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "reasoning", delta: "B" } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(2) + }) + + test("keeps deltas for different messages separate", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "A" } }, + { type: "message.part.delta", properties: { messageID: "m2", partID: "p1", field: "text", delta: "B" } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(2) + }) + + test("preserves causal ordering — clears deltaMap on non-delta events", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "first" } }, + { type: "message.part.updated", properties: { part: { messageID: "m1", id: "p1" } } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "second" } }, + ] + const merged = mergeDeltaEvents(events) + // Should NOT merge "first" and "second" because a non-delta event intervenes + expect(merged).toHaveLength(3) + expect((merged[0] as DeltaEvent).properties.delta).toBe("first") + expect(merged[1].type).toBe("message.part.updated") + expect((merged[2] as DeltaEvent).properties.delta).toBe("second") + }) + + test("resumes merging after non-delta event for new deltas", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "a" } }, + { type: "message.updated", properties: { info: {} } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "b" } }, + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "c" } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(3) + expect((merged[0] as DeltaEvent).properties.delta).toBe("a") + expect((merged[2] as DeltaEvent).properties.delta).toBe("bc") + }) + + test("does not mutate original events", () => { + const event1: DeltaEvent = { + type: "message.part.delta", + properties: { messageID: "m1", partID: "p1", field: "text", delta: "Hello" }, + } + const event2: DeltaEvent = { + type: "message.part.delta", + properties: { messageID: "m1", partID: "p1", field: "text", delta: " world" }, + } + mergeDeltaEvents([event1, event2]) + expect(event1.properties.delta).toBe("Hello") + expect(event2.properties.delta).toBe(" world") + }) + + test("handles empty event list", () => { + const merged = mergeDeltaEvents([]) + expect(merged).toHaveLength(0) + }) + + test("passes through non-delta events unchanged", () => { + const events: Event[] = [ + { type: "session.updated", properties: { info: { id: "s1" } } }, + { type: "message.updated", properties: { info: { id: "m1" } } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(2) + expect(merged[0].type).toBe("session.updated") + expect(merged[1].type).toBe("message.updated") + }) + + test("handles single delta event", () => { + const events: Event[] = [ + { type: "message.part.delta", properties: { messageID: "m1", partID: "p1", field: "text", delta: "solo" } }, + ] + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(1) + expect((merged[0] as DeltaEvent).properties.delta).toBe("solo") + }) + + test("merges many deltas efficiently", () => { + const events: Event[] = Array.from({ length: 100 }, (_, i) => ({ + type: "message.part.delta" as const, + properties: { messageID: "m1", partID: "p1", field: "text", delta: String(i) }, + })) + const merged = mergeDeltaEvents(events) + expect(merged).toHaveLength(1) + expect((merged[0] as DeltaEvent).properties.delta).toBe(Array.from({ length: 100 }, (_, i) => String(i)).join("")) + }) +}) + +// ─── Line Buffer ──────────────────────────────────────────────────────────── +// Extracted from sync.tsx — buffers deltas and flushes only on \n or forceAll. + +class LineBuffer { + private buffers = new Map() + private flushed: { key: string; text: string }[] = [] + + append(messageID: string, partID: string, field: string, delta: string) { + const key = `${messageID}:${partID}:${field}` + this.buffers.set(key, (this.buffers.get(key) ?? "") + delta) + this.flush(messageID, partID, field, false) + } + + flush(messageID: string, partID: string, field: string, forceAll: boolean) { + const key = `${messageID}:${partID}:${field}` + const buffer = this.buffers.get(key) + if (!buffer) return + let textToFlush: string + if (forceAll) { + textToFlush = buffer + this.buffers.delete(key) + } else { + const lastNewline = buffer.lastIndexOf("\n") + if (lastNewline === -1) return + textToFlush = buffer.slice(0, lastNewline + 1) + const remainder = buffer.slice(lastNewline + 1) + if (remainder) this.buffers.set(key, remainder) + else this.buffers.delete(key) + } + if (textToFlush) { + this.flushed.push({ key, text: textToFlush }) + } + } + + flushAllForMessage(messageID: string) { + for (const [key] of this.buffers) { + if (!key.startsWith(messageID + ":")) continue + const [, partID, field] = key.split(":") + this.flush(messageID, partID, field, true) + } + } + + getFlushed() { + return this.flushed + } + + getBuffer(messageID: string, partID: string, field: string) { + return this.buffers.get(`${messageID}:${partID}:${field}`) + } + + reset() { + this.buffers.clear() + this.flushed = [] + } +} + +describe("line buffer", () => { + let lb: LineBuffer + + beforeEach(() => { + lb = new LineBuffer() + }) + + test("buffers text without newlines — nothing flushed", () => { + lb.append("m1", "p1", "text", "Hello world") + expect(lb.getFlushed()).toHaveLength(0) + expect(lb.getBuffer("m1", "p1", "text")).toBe("Hello world") + }) + + test("flushes complete line on newline", () => { + lb.append("m1", "p1", "text", "Hello\n") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("Hello\n") + expect(lb.getBuffer("m1", "p1", "text")).toBeUndefined() + }) + + test("flushes up to last newline, keeps remainder", () => { + lb.append("m1", "p1", "text", "line1\nline2\npartial") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("line1\nline2\n") + expect(lb.getBuffer("m1", "p1", "text")).toBe("partial") + }) + + test("accumulates across multiple appends, flushes on newline", () => { + lb.append("m1", "p1", "text", "Hel") + lb.append("m1", "p1", "text", "lo ") + lb.append("m1", "p1", "text", "wor") + expect(lb.getFlushed()).toHaveLength(0) + lb.append("m1", "p1", "text", "ld\n") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("Hello world\n") + }) + + test("handles multiple newlines in one append", () => { + lb.append("m1", "p1", "text", "a\nb\nc\n") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("a\nb\nc\n") + expect(lb.getBuffer("m1", "p1", "text")).toBeUndefined() + }) + + test("handles newline in middle of append", () => { + lb.append("m1", "p1", "text", "first\nsecond") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("first\n") + expect(lb.getBuffer("m1", "p1", "text")).toBe("second") + }) + + test("forceAll flushes everything including partial line", () => { + lb.append("m1", "p1", "text", "no newline here") + expect(lb.getFlushed()).toHaveLength(0) + lb.flush("m1", "p1", "text", true) + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("no newline here") + expect(lb.getBuffer("m1", "p1", "text")).toBeUndefined() + }) + + test("flushAllForMessage flushes all parts of a message", () => { + lb.append("m1", "p1", "text", "part1 partial") + lb.append("m1", "p2", "text", "part2 partial") + lb.append("m2", "p1", "text", "other msg") + expect(lb.getFlushed()).toHaveLength(0) + + lb.flushAllForMessage("m1") + expect(lb.getFlushed()).toHaveLength(2) + expect( + lb + .getFlushed() + .map((f) => f.text) + .sort(), + ).toEqual(["part1 partial", "part2 partial"]) + // m2 buffer should be untouched + expect(lb.getBuffer("m2", "p1", "text")).toBe("other msg") + }) + + test("flushAllForMessage is safe when no buffers exist", () => { + lb.flushAllForMessage("nonexistent") + expect(lb.getFlushed()).toHaveLength(0) + }) + + test("handles empty delta", () => { + lb.append("m1", "p1", "text", "") + expect(lb.getFlushed()).toHaveLength(0) + expect(lb.getBuffer("m1", "p1", "text")).toBe("") + }) + + test("handles delta that is just a newline", () => { + lb.append("m1", "p1", "text", "\n") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("\n") + }) + + test("handles consecutive newlines", () => { + lb.append("m1", "p1", "text", "\n\n\n") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("\n\n\n") + }) + + test("independent buffers for different fields", () => { + lb.append("m1", "p1", "text", "text content\n") + lb.append("m1", "p1", "reasoning", "thinking...") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe("text content\n") + expect(lb.getBuffer("m1", "p1", "reasoning")).toBe("thinking...") + }) + + test("sequential flushes accumulate correctly", () => { + lb.append("m1", "p1", "text", "line1\n") + lb.append("m1", "p1", "text", "line2\n") + lb.append("m1", "p1", "text", "line3\n") + expect(lb.getFlushed()).toHaveLength(3) + expect(lb.getFlushed().map((f) => f.text)).toEqual(["line1\n", "line2\n", "line3\n"]) + }) + + test("large buffer with many lines", () => { + const lines = Array.from({ length: 50 }, (_, i) => `line ${i}`) + lb.append("m1", "p1", "text", lines.join("\n") + "\npartial") + expect(lb.getFlushed()).toHaveLength(1) + expect(lb.getFlushed()[0].text).toBe(lines.join("\n") + "\n") + expect(lb.getBuffer("m1", "p1", "text")).toBe("partial") + }) +}) + +// ─── Flag Composition ─────────────────────────────────────────────────────── + +// Simulates the flag resolution logic from flag.ts using function calls +// to avoid TypeScript nullish-expression warnings with literal values. +function resolveFlags(opts: { + calmMode: boolean + smoothStreamingFlag: boolean + lineStreamingFlag: boolean + contentWidthEnv: number | undefined + contentWidthFallback: number | undefined +}) { + const smoothStreaming = opts.calmMode || opts.smoothStreamingFlag + const lineStreaming = opts.calmMode || opts.lineStreamingFlag + const contentMaxWidth = opts.contentWidthEnv ?? opts.contentWidthFallback ?? (opts.calmMode ? 100 : undefined) + return { smoothStreaming, lineStreaming, contentMaxWidth } +} + +describe("calm mode flag composition", () => { + test("ALTIMATE_CALM_MODE enables all three sub-flags", () => { + const flags = resolveFlags({ + calmMode: true, + smoothStreamingFlag: false, + lineStreamingFlag: false, + contentWidthEnv: undefined, + contentWidthFallback: undefined, + }) + expect(flags.smoothStreaming).toBe(true) + expect(flags.lineStreaming).toBe(true) + expect(flags.contentMaxWidth).toBe(100) + }) + + test("individual flags work without calm mode", () => { + const flags = resolveFlags({ + calmMode: false, + smoothStreamingFlag: true, + lineStreamingFlag: false, + contentWidthEnv: undefined, + contentWidthFallback: undefined, + }) + expect(flags.smoothStreaming).toBe(true) + expect(flags.lineStreaming).toBe(false) + expect(flags.contentMaxWidth).toBeUndefined() + }) + + test("custom content width overrides calm mode default", () => { + const flags = resolveFlags({ + calmMode: true, + smoothStreamingFlag: false, + lineStreamingFlag: false, + contentWidthEnv: 80, + contentWidthFallback: undefined, + }) + expect(flags.contentMaxWidth).toBe(80) + }) + + test("all flags disabled by default", () => { + const flags = resolveFlags({ + calmMode: false, + smoothStreamingFlag: false, + lineStreamingFlag: false, + contentWidthEnv: undefined, + contentWidthFallback: undefined, + }) + expect(flags.smoothStreaming).toBe(false) + expect(flags.lineStreaming).toBe(false) + expect(flags.contentMaxWidth).toBeUndefined() + }) +}) + +// ─── Content Width Capping ────────────────────────────────────────────────── + +describe("content width capping", () => { + function computeCappedWidth(cap: number | undefined, availableWidth: number): number | undefined { + if (!cap) return undefined + const desired = cap + 3 // +3 for paddingLeft + return availableWidth <= desired ? undefined : desired + } + + test("returns desired width when screen is wide enough", () => { + expect(computeCappedWidth(100, 200)).toBe(103) + }) + + test("returns undefined (no cap) when screen is smaller than cap", () => { + expect(computeCappedWidth(100, 80)).toBeUndefined() + }) + + test("returns undefined when screen equals cap + padding", () => { + expect(computeCappedWidth(100, 103)).toBeUndefined() + }) + + test("returns undefined when cap is undefined", () => { + expect(computeCappedWidth(undefined, 200)).toBeUndefined() + }) + + test("returns undefined when cap is 0", () => { + expect(computeCappedWidth(0, 200)).toBeUndefined() + }) + + test("works with very small screens", () => { + expect(computeCappedWidth(100, 40)).toBeUndefined() + }) + + test("works with very large caps", () => { + expect(computeCappedWidth(300, 400)).toBe(303) + }) + + test("works with cap of 1", () => { + expect(computeCappedWidth(1, 10)).toBe(4) + }) +})