/** * Pure session-event-to-view projection for the TUI transcript: one reducer * over {@link SessionEvent}s producing the ordered entries the renderer draws. * Rendering never reads the session directly — this module owns the view * model, so tests drive it with plain event arrays. * * @module @deepseek-ai/dsh-tui/render/projection */ import { type ImageBlock, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'; import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'; import type { SessionEvent } from '@deepseek-ai/dsh-session'; import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'; import { type ToolDetail } from './tool-detail.ts'; /** One user prompt line. */ export interface UserEntry { kind: 'user'; /** Joined text blocks of the user message. */ text: string; /** True for collapsed injected context (plugin/continuation notices), which * the renderer marks with a dim ↳ instead of the user ❯ prompt. */ notice: boolean; /** Durable image references carried by this prompt. */ images?: readonly ImageBlock['attachment'][]; /** Durable file references carried by this prompt (0.1.5 file blocks). */ files?: readonly FileAttachmentRef[]; /** * How the prompt reached the agent, when it did not arrive as an ordinary * submission: `queued` waited for this turn, `steered` joined it mid-flight. * Absent for a prompt typed straight into an idle composer. */ delivery?: 'queued' | 'steered'; } /** One user message waiting in the agent inbox (the web's queued-message row). */ export interface PendingEntry { kind: 'pending'; /** Stable message identity shared with the durable `user/message` that retires it. */ messageId: MessageId; /** Which inbox list holds the message: steering is consumed at the next step boundary. */ target: 'next-turn' | 'next-step'; /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */ text: string; /** Durable image references queued with this prompt. */ images?: readonly ImageBlock['attachment'][]; /** Durable file references queued with this prompt (0.1.5 file blocks). */ files?: readonly FileAttachmentRef[]; } /** One authoritative assembled assistant reply. */ export interface AssistantEntry { kind: 'assistant'; /** Joined text blocks of the assistant message. */ text: string; /** Joined reasoning blocks from the same assembled message. */ reasoning: string; /** True when a cancelled stream's delivered prefix was finalized as this * entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */ interrupted?: true; } /** One model-requested tool invocation and its settled state. */ export interface ToolEntry { kind: 'tool'; /** Correlation id shared with the matching `tool/result`. */ callId: string; /** * Global tool-call ordinal across the whole transcript (1, 2, 3…, never * reset between turns). The tool-card badge and every error line that * references the failed call share this number, so "call N" in an error * always names the exact card the badge shows. */ ordinal: number; /** Tool name as the model addressed it. */ name: string; /** Raw arguments JSON string exactly as the model produced it. */ arguments: string; /** Bounded human-meaningful arguments preview for the tool card. */ preview: string; /** Bounded delegation prompt (subagent cards' second row), '' when none. */ prompt: string; /** Execution state; `running` until the paired result lands. */ state: 'running' | 'done' | 'error'; /** Bounded first text block of the result, empty until it lands. */ summary: string; /** * Bounded expansion payload for the verbose transcript (Ctrl+O), derived * from the tool's persisted presentation metadata; undefined until the * result lands and only when something renderable exists. */ detail: ToolDetail | undefined; /** * Nested PTC sub-dispatches (`run_code`) in start order, bounded to the * newest {@link MAX_TOOL_SUB_DISPATCHES} rows. */ subs: readonly ToolSubDispatch[]; /** Sub-dispatches evicted from the bounded window. */ subsDropped: number; } /** * One nested PTC sub-dispatch under a `run_code` parent call: the durable * `tool/ptc-dispatch-start`/`tool/ptc-dispatch` pair folded to one bounded * row (upstream contract: pair by `subCallId`, every start settles, and the * settle carries `tool/result`'s own vocabulary). */ export interface ToolSubDispatch { /** Opaque sub-call id pairing the start with its settle. */ subCallId: string; /** Sub-call tool name. */ name: string; /** Bounded arguments preview. */ preview: string; /** Lifecycle; `running` until the paired settle lands. */ state: 'running' | 'done' | 'error'; /** Bounded first text block of the settle content, '' until it lands. */ summary: string; /** Wall-clock duration (settle − start), 0 while running. */ durationMs: number; } /** Bounded sub-dispatch window per tool card (display budget only). */ export declare const MAX_TOOL_SUB_DISPATCHES = 12; /** Bounded member window per workflow run card (display budget only). */ export declare const MAX_WORKFLOW_MEMBERS = 12; /** One workflow member (an `agent()` call inside a `workflow` script). */ export interface WorkflowMember { /** Member sequence within the run (the agent-start/agent-end pairing key). */ seq: number; /** Display label. */ label: string; /** Declared phase title, '' when none. */ phase: string; /** Child session id (cross-links the subagent feed's rows). */ childId: string; /** Settlement; `running` until the paired `tool-workflow/agent-end`. */ outcome: 'running' | 'completed' | 'failed' | 'cancelled'; } /** * One durable workflow run (the `tool-workflow/*` record a `workflow` or * `ralph` tool appends to the parent session): run identity plus its * bounded member list, live until `tool-workflow/run-end` settles. */ export interface WorkflowEntry { kind: 'workflow'; /** Stable run identity shared by every event of the run. */ runId: string; /** Display name of the run. */ name: string; /** Members in sequence order, bounded to the newest window. */ members: readonly WorkflowMember[]; /** Members evicted from the bounded window. */ membersDropped: number; /** Run settlement; `running` until `tool-workflow/run-end`. */ state: 'running' | 'completed' | 'cancelled' | 'error'; } /** One slash-command execution dispatched through `ctx.commands`. */ export interface CommandEntry { kind: 'command'; /** Pairing id shared with the matching `command/done`. */ commandId: string; /** Lowercase command name without the leading slash. */ name: string; /** Verbatim text following the command name. */ args: string; /** Execution state; `running` until the paired lifecycle event lands. */ state: 'running' | 'done' | 'error'; /** Handler outcome text, empty until it lands. */ summary: string; } /** One turn-level failure surfaced from `turn/end`. */ export interface ErrorEntry { kind: 'error'; /** `code: message` of the failure. */ text: string; } /** One non-error turn outcome surfaced from `turn/end`. */ export interface TurnMarkerEntry { kind: 'turn-marker'; /** Human-readable outcome line, dim-rendered. */ text: string; } /** One completed compaction lifecycle surfaced from `compaction/end`. */ export interface CompactionEntry { kind: 'compaction'; /** True when the compaction completed, false when it failed. */ ok: boolean; /** Heuristic tokens shadowed by the compaction (summary or prune price). */ tokens: number; /** Failure text when `ok` is false, empty otherwise. */ error: string; } /** One provider-routed model-request retry (the `llm/retry` pair). */ export interface RetryEntry { kind: 'retry'; /** Correlation id shared with the matching `llm/retry-started`. */ retryId: string; /** Retry policy mode from the event: `always` has no attempt cap. */ mode: 'normal' | 'always'; /** Attempt ordinal and its cap. */ attempt: number; max: number; /** Failure code that triggered the retry. */ code: string; /** Backoff wait before the next attempt, in ms. */ delayMs: number; /** * `running` while the backoff waits, `done` once the attempt started — or * when the turn ended first (the turn-end sweep finalizes orphans so they * never pin the settled boundary). */ state: 'running' | 'done'; } /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */ export interface FilesEntry { kind: 'files'; /** Unique mutated paths in call order, bounded. */ paths: readonly string[]; } /** Ordered transcript items the renderer draws. */ export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry | WorkflowEntry; /** The live goal the status line badges, folded from `goal/change`. */ export interface GoalFold { /** Human-requested completion objective. */ objective: string; /** Durable lifecycle phase. */ phase: 'active' | 'paused' | 'blocked' | 'complete'; /** Highest admitted continuation round and its cap. */ rounds: number; max: number; /** Blocked explanation, empty outside the blocked phase. */ blocked: string; } /** * Cumulative token accounting folded from `assistant/message` usage reports. * The buckets are disjoint and mirror the provider's report, so the prompt * side is never double counted: reasoning tokens are already inside * `outputTokens`, and cache reads are never folded into the uncached input. */ export interface UsageTotals { /** Prompt-side tokens billed outside the cache. */ uncachedInputTokens: number; /** Completion-side tokens over the whole log. */ outputTokens: number; /** Cache-read tokens over the whole log (0 when the adapter reports none). */ cacheReadTokens: number; /** Cache-write tokens over the whole log (0 when the adapter reports none). */ cacheWriteTokens: number; } /** * Estimated used tokens per context content type, folded from transcript * events via {@link estimateTokens}. The segmented context bar's composition * source: proportions across types are meaningful, absolute values are not * (they never touch billing or the reported `lastPromptTokens`). */ export interface ContextSegments { /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */ system: number; /** Direct human prompts (durable `user/message` rows). */ prompt: number; /** Assistant text blocks (visible replies). */ assistant: number; /** Assistant reasoning blocks (hidden thinking). */ thinking: number; /** Tool call arguments plus result text. */ tools: number; } /** Window-scoped figures the status line shows; timing uses event timestamps. */ export interface TranscriptStats { /** Durable turns opened (`turn/start` events). */ turns: number; /** Model requests made (`step/start` events). */ steps: number; /** Summed model wall time: `step/start` → `assistant/message`, in ms. */ llmMs: number; /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */ toolMs: number; /** Cumulative token accounting; input stays 0 until a report lands. */ usage: UsageTotals; /** Prompt-side size of the most recent reported request (context pressure). */ lastPromptTokens: number; /** Newest advertised route capacity, 0 when no adapter ever advertised one. */ contextWindow: number; /** Estimated used tokens per content type (the segmented bar's composition). */ contextSegments: ContextSegments; /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */ ttftMs: number; /** Steps that produced a first chunk (the TTFT average's denominator). */ ttftSteps: number; /** Summed decode spans: first chunk → `assistant/message`, in ms. */ decodeMs: number; /** Completion tokens over timed decode spans (the tok/s numerator). */ decodeTokens: number; /** * Adapter-owned reasoning effort of the latest `request/header` config — * the EFFECTIVE effort the session actually uses (a materialized model * default is included, exactly as the adapter resolved it). Empty when the * header carried none (provider-default behavior). The status line appends * it to the model segment as `provider/model@effort`. */ reasoningEffort: string; } /** One active reminder folded from durable `schedule/change` events. */ export interface ScheduleRow { readonly id: string; readonly kind: 'after' | 'at' | 'every'; readonly prompt: string; /** Next due time (epoch ms); the /schedule panel derives overdue/relative labels. */ readonly targetAt: number; /** Recurrence seconds for 'every' rows, undefined otherwise. */ readonly everySeconds?: number; } /** * The durable `schedule/change` payload shape this fold consumes. Upstream * strict-decodes the whole transition stream before appending, so unknown * ids here are corrupt-input edges that degrade to a no-op. */ export interface ScheduleChangeLike { readonly operation: 'create' | 'delete' | 'dispatch'; readonly schedule?: { readonly id: string; readonly kind: 'after' | 'at' | 'every'; readonly prompt: string; readonly afterSeconds?: number; readonly everySeconds?: number; readonly scheduledAt: string; }; readonly id?: string; readonly acceptedAt?: string; } /** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */ export declare function applyScheduleChange(rows: readonly ScheduleRow[], data: ScheduleChangeLike): readonly ScheduleRow[]; /** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */ export declare function nextEveryTarget(previousTarget: number, acceptedAt: number, everySeconds: number): number; /** The complete TUI transcript view for one session. */ export interface TranscriptView { /** Settled entries in log order. */ entries: readonly TranscriptEntry[]; /** Bounded text tail accumulated from live stream frames since the last settlement. */ streaming: string; /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */ streamingReasoning: string; /** Latest whole-list todo snapshot from `todo/write`, empty when none. */ todos: readonly TodoItem[]; /** * Global tool-call ordinal counter: the number the NEXT `tool/call` lands * with (1-based). Never reset, so the counter and the badges/error lines * stay consistent across turns and resumed sessions. */ toolCallOrdinal: number; /** True while a durable turn is open (`turn/start` … `turn/end`). */ busy: boolean; /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */ busySince: number; /** Figures the status line renders. */ stats: TranscriptStats; /** * The `provider/model` pair of the last `request/header` snapshot — the * session's own model record, which a resumed TUI prefers over the * deployment default (mirrors the web host's resume selection order). * Empty before the session's first request. */ model: string; /** Plan mode state folded from the last `plan/mode` event. */ plan: boolean; /** Active permission preset folded from the last `permission/preset` event, empty before one. */ permission: string; /** Latest session title folded from the last `session/title` event, empty before one. */ title: string; /** * Effective system prompt assembled from `system/message` surface nodes * (v3): the head node's text joined with every later non-empty node, blank * lines between. Empty before the first system node or when every node is * empty ("no system prompt"). */ systemPrompt: string; /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */ sandbox: string; /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */ goal: GoalFold | undefined; /** Active reminders folded from `schedule/change` events, oldest target first at render. */ schedules: readonly ScheduleRow[]; /** * Ordered live message ids per inbox target, mirrored from * `agent/inbox/spliced` exactly like the upstream Inbox projection — the * coordinates later removals resolve against. */ pending: { 'next-turn': readonly string[]; 'next-step': readonly string[]; }; /** * In-flight inbox messages by the list they were inserted into, kept until * the durable user message that claims them lands. The claim itself is a * plain splice that empties the pending row first, so this map — not * {@link pending} — is what lets a settled prompt say it was queued or * steered rather than typed into an idle composer. */ claimOrigin: ReadonlyMap; /** * Fold-internal timing anchors, never rendered: open step and tool-call * start timestamps the next `assistant/message` / `tool/result` resolves * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools` * track which step/tool anchors still belong to the open turn so * `turn/end` (and a superseding `step/start`) can sweep anchors an * interruption left behind; `turnFiles` keys mutated paths by turn. */ readonly anchors: { stepStart: Map; toolStart: Map; /** Open PTC sub-dispatch starts by `subCallId` (duration anchors). */ subStart: Map; firstChunkAt: Map; compactionTokens: Map; lastPruneTokens: number; turnFiles: Map>; turnSteps: Map; turnTools: Map>; /** Live `system/message` surface nodes by event seq (empty string = an empty node). */ systemNodes: Map; }; } /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */ export declare function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string; /** Human-readable bounded file labels for the same surfaces (0.1.5 file blocks). */ export declare function fileLabels(files: readonly FileAttachmentRef[] | undefined): string; /** Prompt text with its durable image and file labels, without exposing local paths or bytes. */ export declare function promptDisplayText(entry: Pick): string; /** A fresh, empty transcript view. */ export declare function createTranscriptView(): TranscriptView; /** * Fold one session event into an updated view (copy-on-write). * @param view - the view before the event. * @param event - one durable session event from `session/event` or the log. * @returns the view after the event; the input view is never mutated. */ export declare function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView; /** * Mutable replay accumulator: folds a persisted log into the identical view * `projectEvent` would produce, but in near-linear time. Where `projectEvent` * is copy-on-write — every append/scan rebuilds the whole `entries` array, so * folding a full log costs O(N²) — the accumulator appends by push, resolves * id-keyed updates (tool/result, command/done, retry-started) through index * maps, and tombstones retired pending rows, so the whole log folds in O(N) * plus one compaction pass when tombstones exist. * * Index maps never delete: every appended row registers its index, so an id * lookup miss provably means no matching row exists and the update is an O(1) * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array * scans). Each id maps to ALL of its indices, so a duplicate id updates every * matching row exactly like the copy-on-write reducer. * * @internal Exported only so tests can (a) prove replay ≡ sequential * `projectEvent` folds and (b) assert the linear complexity deterministically * via {@link ReplayAccumulator.ops}, which counts entry-level container work * instead of relying on wall-clock thresholds. No public consumer. */ export interface ReplayAccumulator { /** Working entry list; `undefined` marks a retired pending row (tombstone). */ entries: (TranscriptEntry | undefined)[]; /** callId → every index into `entries` holding a `tool` row with that id. */ toolIndex: Map; /** commandId → every index into `entries` holding a `command` row with that id. */ commandIndex: Map; /** retryId → every index into `entries` holding a `retry` row with that id. */ retryIndex: Map; /** messageId → every index into `entries` holding a `pending` row with that id. */ pendingIndex: Map; /** runId → every index into `entries` holding a `workflow` row with that id. */ workflowIndex: Map; /** Tombstone count; zero means `entries` is already the final array. */ removedCount: number; /** Mutable inbox id lists, mirroring `view.pending` order per target. */ pendingTurn: string[]; pendingStep: string[]; /** Mutable mirror of `view.claimOrigin` (see the reducer's field doc). */ claimOrigin: Map; streaming: string; streamingReasoning: string; todos: readonly TodoItem[]; /** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */ toolCallOrdinal: number; busy: boolean; busySince: number; model: string; plan: boolean; permission: string; title: string; systemPrompt: string; sandbox: string; goal: GoalFold | undefined; schedules: readonly ScheduleRow[]; stats: TranscriptStats; stepStart: Map; toolStart: Map; /** Open PTC sub-dispatch starts by `subCallId` (duration anchors). */ subStart: Map; firstChunkAt: Map; compactionTokens: Map; lastPruneTokens: number; turnFiles: Map>; turnSteps: Map; turnTools: Map>; /** Live `system/message` surface nodes by event seq (empty string = an empty node). */ systemNodes: Map; /** Entry-level container operations performed so far (test instrumentation). */ ops: number; } /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */ export declare function createReplayAccumulator(): ReplayAccumulator; /** * Fold one session event into a replay accumulator. This mirrors * {@link projectEvent} case for case — same stats arithmetic, same anchor * set/delete behavior, same entry shapes — so the finished view is identical * to a sequential fold; only the `entries` container operations are mutable. * * @internal Test-instrumentation path; `projectEvents` is the public entry. * @returns whether the event changed the accumulated state — the live store * stays silent and keeps its snapshot identity for ignored events, exactly * like the copy-on-write reducer returning its input view unchanged. */ export declare function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean; /** * Materialize the accumulated fold as a `TranscriptView`, compacting any * retired tombstones. The anchors maps are handed through as-is (their * content is identical to a sequential fold's). * * @internal Test-instrumentation path; `projectEvents` is the public entry. */ export declare function finishReplay(acc: ReplayAccumulator): TranscriptView; /** * Materialize the accumulated fold as a fresh immutable snapshot for the * live store. Unlike {@link finishReplay} — the one-shot replay entry, which * hands the accumulator's own arrays through because the accumulator is * discarded — every array a renderer can hold is copied here, so later * folds never mutate a snapshot already handed out. Same fields, same * tombstone compaction. * * @internal Live-store path; `projectEvents` is the public entry. */ export declare function snapshotReplayView(acc: ReplayAccumulator): TranscriptView; /** * Fold a replayed event history into one view. * * Folding is near-linear in the log size: the mutable replay accumulator * appends in place and resolves id-keyed updates through index maps, so a * long persisted session replays without the O(N²) copy-on-write rebuilds a * naive sequential fold would incur. The result is identical to folding * {@link projectEvent} per event in order. * @param events - events in `seq` order. * @returns the folded view. */ export declare function projectEvents(events: readonly SessionEvent[]): TranscriptView; /** * Fold one process-local assistant-stream chunk frame (session-log v2+ keeps * durable logs settlement-only; live typing rides the `agent/assistant-stream` * agent event). Same first-token anchoring the durable `assistant/chunk` event * used to carry: the first non-empty delta anchors the TTFT and empty * keep-alive deltas do not count. The caller maps the frame's attempt to the * `turn:step` key (the start frame owns turn/step; chunk frames do not). * @param acc - the live replay accumulator. * @param key - the `turn:step` key the attempt's start frame declared. * @param time - the frame's safe-integer timestamp. * @param chunk - the model chunk the frame carries. * @returns whether the accumulator changed (the store stays silent otherwise). */ export declare function applyAssistantStreamChunk(acc: ReplayAccumulator, key: string, time: number, chunk: StreamChunk): boolean; /** * Drop the live streaming tails without a settlement (an `agent/assistant-stream` * end frame with an `abandoned` outcome, or a session switch). The next start * frame rebuilds from scratch. * @param acc - the live replay accumulator. * @returns whether any tail text was discarded. */ export declare function clearAssistantStream(acc: ReplayAccumulator): boolean; /** * The append-only flush boundary for a transcript view: the count of entries * no later event can remove. Entries at or beyond this index are mutable and * must stay in the live tree. * * `pending` rows are excluded even though they are not a running tool/retry: * the inbox claims or cancels them durably (`agent/inbox/spliced` removals, * `user/message` retirement), and an append-only `` flush cannot * erase a row that vanishes from the view — the retired row would ghost on * screen until the next source-backed replay. Running commands join the * mutable boundary for the same reason in reverse: `command/done` mutates the * row's state/summary, so a flushed row would keep its stale running mark * until a resize-triggered replay. Everything else (including a completed * tail) is final: later events only APPEND new rows. * @param entries - the view's transcript entries in order. * @returns the count of entries safe to flush (0 for an empty transcript). */ export declare function settledEntryCount(entries: readonly TranscriptEntry[]): number;