dsh-code 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,8 @@ import type { PluginRow } from './plugin-inventory.ts'
7
7
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
8
8
  import { panelViewport, revealRow } from './render/inspector.ts'
9
9
  import { textLines } from './render/lines.ts'
10
- import { singleLineText, truncateColumns } from './render/text.ts'
10
+ import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
11
+ import { displayText, singleLineText, truncateColumns } from './render/text.ts'
11
12
  import { TUI_RGB } from './theme.ts'
12
13
 
13
14
  function color(rgb: readonly [number, number, number]): string {
@@ -43,7 +44,7 @@ function ListFrame(props: ListFrameProps): ReactElement {
43
44
  const visible = stateRows.slice(offset, offset + bodyRows)
44
45
  return createElement(
45
46
  Box,
46
- { borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
47
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
47
48
  createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(props.title, viewport.contentColumns)),
48
49
  createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`search: ${props.query === '' ? 'type to filter' : props.query}`, viewport.contentColumns)),
49
50
  ...visible.map((row, index) => {
@@ -246,9 +247,173 @@ function DocumentPanel({ title, text, error, close }: {
246
247
  : lines.slice(scroll, scroll + viewport.bodyRows)
247
248
  return createElement(
248
249
  Box,
249
- { borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
250
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
250
251
  createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)),
251
252
  ...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
252
253
  createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)),
253
254
  )
254
255
  }
256
+
257
+ /**
258
+ * The /history recall panel (Codex composer-history search, bounded): one
259
+ * query line over the newest-first recall space, filtered by substring, with
260
+ * arrow selection and enter to fill the composer. Editing the query restarts
261
+ * from the newest match; Esc closes without touching the draft.
262
+ */
263
+ export function HistoryPanel({ entries, fill, close }: {
264
+ /** Newest-first recall entries (persistent + in-session, deduped). */
265
+ entries: readonly string[]
266
+ /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
267
+ fill(text: string, index: number): void
268
+ close(): void
269
+ }): ReactElement {
270
+ const [query, setQuery] = useState('')
271
+ const [cursor, setCursor] = useState(0)
272
+ const matches = query === ''
273
+ ? entries
274
+ : entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
275
+ useInput((input, key) => {
276
+ if (key.escape) return close()
277
+ if (key.return) {
278
+ const entry = matches[cursor]
279
+ if (entry !== undefined) fill(entry, entries.indexOf(entry))
280
+ return
281
+ }
282
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
283
+ if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
284
+ if (input === 'g') return setCursor(0)
285
+ if (input === 'G') return setCursor(matches.length - 1)
286
+ if (key.backspace) {
287
+ setQuery(current => current.slice(0, -1))
288
+ setCursor(0)
289
+ return
290
+ }
291
+ if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
292
+ setQuery(current => (current + input).slice(0, 120))
293
+ setCursor(0)
294
+ }
295
+ })
296
+ const stdout = useStdout().stdout
297
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
298
+ if (viewport.compact) {
299
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
300
+ }
301
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
302
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
303
+ const offset = revealRow(0, cursor, matches.length, bodyRows)
304
+ const visible = matches.slice(offset, offset + bodyRows)
305
+ const header = query === ''
306
+ ? `/history · ${entries.length} prompts · type to filter`
307
+ : `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
308
+ return createElement(
309
+ Box,
310
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
311
+ createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
312
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
313
+ ...(visible.length === 0
314
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
315
+ : visible.map((entry, index) => {
316
+ const absolute = offset + index
317
+ const selected = absolute === cursor
318
+ return createElement(
319
+ Text,
320
+ {
321
+ key: `history-${absolute}`,
322
+ color: selected ? color(TUI_RGB.brandBright) : undefined,
323
+ wrap: 'truncate-end',
324
+ },
325
+ truncateColumns((selected ? '› ' : ' ') + displayText(entry), viewport.contentColumns),
326
+ )
327
+ })),
328
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
329
+ )
330
+ }
331
+
332
+ /**
333
+ * The /statusline picker (the Codex setup-view contract): one bounded list
334
+ * of every status item with its enabled mark, arrow reordering, and a
335
+ * live preview — the real status line under the composer updates as you
336
+ * edit, so the panel itself carries no duplicate preview row.
337
+ */
338
+ export function StatuslinePanel({ enabled, change, close }: {
339
+ enabled: readonly StatusItemId[]
340
+ change(items: readonly StatusItemId[]): void
341
+ close(): void
342
+ }): ReactElement {
343
+ // Working state: the full catalog in display order (enabled entries in
344
+ // their configured positions, disabled ones trailing canonically) plus
345
+ // the enabled set. Persisted shape is the enabled subsequence only.
346
+ const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
347
+ const seen = new Set(enabled)
348
+ return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
349
+ })
350
+ const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
351
+ const [cursor, setCursor] = useState(0)
352
+ const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
353
+ setOrder(nextOrder)
354
+ setOn(nextOn)
355
+ change(nextOrder.filter(id => nextOn.has(id)))
356
+ }
357
+ const move = (offset: number): void => {
358
+ const target = cursor + offset
359
+ if (target < 0 || target >= order.length) return
360
+ const next = [...order]
361
+ const [item] = next.splice(cursor, 1)
362
+ next.splice(target, 0, item!)
363
+ commit(next, on)
364
+ setCursor(target)
365
+ }
366
+ useInput((input, key) => {
367
+ if (key.escape || input === 'q' || key.return) return close()
368
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
369
+ if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
370
+ if (key.leftArrow) return move(-1)
371
+ if (key.rightArrow) return move(1)
372
+ if (input === 'g') return setCursor(0)
373
+ if (input === 'G') return setCursor(order.length - 1)
374
+ if (input === 'd') {
375
+ commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
376
+ setCursor(0)
377
+ return
378
+ }
379
+ if (input === ' ') {
380
+ const item = order[cursor]
381
+ if (item === undefined) return
382
+ const next = new Set(on)
383
+ if (next.has(item)) next.delete(item)
384
+ else next.add(item)
385
+ commit(order, next)
386
+ }
387
+ })
388
+ const stdout = useStdout().stdout
389
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
390
+ if (viewport.compact) {
391
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
392
+ }
393
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
394
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
395
+ const offset = revealRow(0, cursor, order.length, bodyRows)
396
+ const visible = order.slice(offset, offset + bodyRows)
397
+ const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
398
+ return createElement(
399
+ Box,
400
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
401
+ createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
402
+ ...visible.map((id, index) => {
403
+ const absolute = offset + index
404
+ const selected = absolute === cursor
405
+ const info = meta.get(id)
406
+ return createElement(
407
+ Text,
408
+ {
409
+ key: id,
410
+ color: selected ? color(TUI_RGB.brandBright) : undefined,
411
+ dimColor: !on.has(id) || undefined,
412
+ wrap: 'truncate-end',
413
+ },
414
+ truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
415
+ )
416
+ }),
417
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
418
+ )
419
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Terminal animation frame tables derived from the web design language:
3
3
  * the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
4
- * steps, 1s cycle) becomes the single-cell stepped pulse below, and the
4
+ * steps, 1s cycle) becomes the single-cell stepped pulse below and the
5
+ * full-ring clockwise braille chase in {@link BUSY_CHASE_FRAMES}, and the
5
6
  * streaming caret blink is the Claude-Code convention. Pure functions only —
6
7
  * the Ink layer owns timers and colors.
7
8
  *
@@ -16,6 +17,18 @@ export function pulseFrame(tick: number): string {
16
17
  return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0]
17
18
  }
18
19
 
20
+ /**
21
+ * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
22
+ * ring trail clockwise around the eight outer positions, one braille glyph
23
+ * per step — 8 frames × 125ms = the web's 1s cycle.
24
+ */
25
+ export const BUSY_CHASE_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
26
+
27
+ /** Chase frame for a monotonic tick (the busy composer/Deep-diving marker). */
28
+ export function busyChaseFrame(tick: number): string {
29
+ return BUSY_CHASE_FRAMES[tick % BUSY_CHASE_FRAMES.length] ?? BUSY_CHASE_FRAMES[0]
30
+ }
31
+
19
32
  /** Caret visibility: half the ticks on, half off (530ms blink). */
20
33
  export function caretVisible(tick: number): boolean {
21
34
  return tick % 2 === 0
@@ -66,6 +66,10 @@ export function buildExportMarkdown(view: TranscriptView, sessionId: string): st
66
66
  case 'files':
67
67
  out.push(`> files changed: ${entry.paths.join(', ')}`, '')
68
68
  break
69
+ case 'pending':
70
+ // Codex PendingSteer: queued prompts export like ordinary user rows.
71
+ out.push('## user', '', entry.text, '')
72
+ break
69
73
  default:
70
74
  assertNever(entry, 'transcript entry kind')
71
75
  }
@@ -10,12 +10,14 @@ export interface InspectorViewport {
10
10
  gapRows: 0 | 2
11
11
  /** Columns available inside the horizontal border and padding. */
12
12
  contentColumns: number
13
+ /** Safe outer width for a bordered dynamic panel; never writes column N. */
14
+ outerColumns: number
13
15
  /** Tiny terminals use a borderless one-line close hint. */
14
16
  compact: boolean
15
17
  }
16
18
 
17
- /** Composer/status chrome plus one optional, fixed-height local notice row. */
18
- const INSPECTOR_CHROME_ROWS = 5
19
+ /** Composer (3) + two-row status chrome, plus one optional fixed-height local notice row. */
20
+ const INSPECTOR_CHROME_ROWS = 6
19
21
 
20
22
  /** One transcript-to-composer gutter, collapsed on short terminals. */
21
23
  export function layoutGutterRows(rows: number): 0 | 1 {
@@ -39,11 +41,18 @@ export function panelViewport(columns: number, rows: number): InspectorViewport
39
41
  ))
40
42
  const compact = maxHeight < 5 || safeColumns < 8
41
43
  const gapRows = !compact && maxHeight >= 7 ? 2 : 0
44
+ // A terminal may autowrap a glyph written into its final column. Ink still
45
+ // accounts for that border as one logical row, so the next dynamic update
46
+ // erases too few physical rows and leaves stacked frames behind. Codex
47
+ // renders overlays within an inset surface; reserve the final column here
48
+ // so every Ink panel follows the same contract.
49
+ const outerColumns = compact ? safeColumns : Math.max(1, safeColumns - 1)
42
50
  return {
43
51
  maxHeight,
44
52
  bodyRows: compact ? 0 : maxHeight - 4 - gapRows,
45
53
  gapRows,
46
- contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
54
+ contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, outerColumns - 4),
55
+ outerColumns,
47
56
  compact,
48
57
  }
49
58
  }
@@ -143,16 +143,27 @@ export function transcriptEntryLines(entry: TranscriptEntry, columns: number): r
143
143
  lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
144
144
  lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
145
145
  ], width)
146
- case 'assistant':
147
- return [
148
- ...(entry.reasoning === ''
149
- ? []
150
- : styledLines([
151
- lineSegment(' ✻ ', 'dimItalic'),
152
- lineSegment(entry.reasoning, 'dimItalic'),
153
- ], width)),
154
- ...markdownLines(entry.text, width),
155
- ]
146
+ case 'pending':
147
+ // Codex PendingSteer: a queued prompt renders exactly like an ordinary
148
+ // user row, so the durable user/message retires it without any flicker.
149
+ return styledLines([
150
+ lineSegment('❯ ', 'brand'),
151
+ lineSegment(entry.text, 'plain'),
152
+ ], width)
153
+ case 'assistant': {
154
+ const reasoning = entry.reasoning === ''
155
+ ? []
156
+ : styledLines([
157
+ lineSegment(' ✻ ', 'dimItalic'),
158
+ lineSegment(entry.reasoning, 'dimItalic'),
159
+ ], width)
160
+ // Every reply row carries the composer's two-column gutter, so reply
161
+ // text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
162
+ // wrap budget shrinks by the same amount so no line double-wraps.
163
+ const body = markdownLines(entry.text, Math.max(10, width - 2))
164
+ .map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
165
+ return [...reasoning, ...body]
166
+ }
156
167
  case 'tool': {
157
168
  const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
158
169
  const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
@@ -7,12 +7,13 @@
7
7
  * @module @deepseek-ai/dsh-tui/render/projection
8
8
  */
9
9
 
10
- import { boundContextSummary, type ContentBlock } from '@deepseek-ai/dsh-llm'
10
+ import { boundContextSummary, type ContentBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
11
  import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
12
  // Type-only imports merge the plugin-owned SessionEventMap variants
13
- // (command/*, compaction/*, goal/change, llm/retry*, plan/mode,
14
- // permission/preset, sandbox/mode, session/title) into the union this
15
- // reducer switches on.
13
+ // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
14
+ // plan/mode, permission/preset, sandbox/mode, session/title) into the union
15
+ // this reducer switches on.
16
+ import type {} from '@deepseek-ai/dsh-agent'
16
17
  import type {} from '@deepseek-ai/dsh-commands'
17
18
  import type {} from '@deepseek-ai/dsh-compaction'
18
19
  import type {} from '@deepseek-ai/dsh-goal'
@@ -43,6 +44,17 @@ export interface UserEntry {
43
44
  notice: boolean
44
45
  }
45
46
 
47
+ /** One user message waiting in the agent inbox (the web's queued-message row). */
48
+ export interface PendingEntry {
49
+ kind: 'pending'
50
+ /** Stable message identity shared with the durable `user/message` that retires it. */
51
+ messageId: MessageId
52
+ /** Which inbox list holds the message: steering is consumed at the next step boundary. */
53
+ target: 'next-turn' | 'next-step'
54
+ /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
55
+ text: string
56
+ }
57
+
46
58
  /** One assembled assistant reply. */
47
59
  export interface AssistantEntry {
48
60
  kind: 'assistant'
@@ -139,7 +151,7 @@ export interface FilesEntry {
139
151
  }
140
152
 
141
153
  /** Ordered transcript items the renderer draws. */
142
- export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
154
+ export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
143
155
 
144
156
  /** The live goal the status line badges, folded from `goal/change`. */
145
157
  export interface GoalFold {
@@ -223,6 +235,12 @@ export interface TranscriptView {
223
235
  sandbox: string
224
236
  /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
225
237
  goal: GoalFold | undefined
238
+ /**
239
+ * Ordered live message ids per inbox target, mirrored from
240
+ * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
241
+ * coordinates later removals resolve against.
242
+ */
243
+ pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
226
244
  /**
227
245
  * Fold-internal timing anchors, never rendered: open step and tool-call
228
246
  * start timestamps the next `assistant/message` / `tool/result` resolves
@@ -256,11 +274,17 @@ export function createTranscriptView(): TranscriptView {
256
274
  title: '',
257
275
  sandbox: '',
258
276
  goal: undefined,
277
+ pending: { 'next-turn': [], 'next-step': [] },
259
278
  stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 },
260
279
  anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map() },
261
280
  }
262
281
  }
263
282
 
283
+ /** Full prompt text of a queued message (identical to the durable user row it retires into). */
284
+ function pendingText(content: readonly ContentBlock[]): string {
285
+ return textOf(content)
286
+ }
287
+
264
288
  /**
265
289
  * Fold one session event into an updated view (copy-on-write).
266
290
  * @param view - the view before the event.
@@ -270,17 +294,56 @@ export function createTranscriptView(): TranscriptView {
270
294
  export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
271
295
  switch (event.type) {
272
296
  case 'user/message': {
297
+ // A queued row retires when its durable user message lands (the agent
298
+ // claims the inbox and logs the same message identity) — the transient
299
+ // steering/queued preview yields to the real transcript entry.
300
+ const message = event.data
301
+ let entries = view.entries
302
+ let pending = view.pending
303
+ for (const target of ['next-turn', 'next-step'] as const) {
304
+ const index = pending[target].indexOf(message.id)
305
+ if (index < 0) continue
306
+ pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
307
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
308
+ }
273
309
  // Injected context (plugin/model-continuation sources) stays collapsed
274
310
  // to a bounded notice row, exactly like collapsed transcript context
275
311
  // elsewhere in the product; only direct human prompts render in full.
276
- const message = event.data
277
312
  if (message.source.kind === 'user') {
278
- return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content), notice: false }] }
313
+ return { ...view, pending, entries: [...entries, { kind: 'user', text: textOf(message.content), notice: false }] }
279
314
  }
280
315
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
281
316
  ? message.source.summary
282
317
  : message.source.kind
283
- return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice), notice: true }] }
318
+ return { ...view, pending, entries: [...entries, { kind: 'user', text: boundContextSummary(notice), notice: true }] }
319
+ }
320
+ case 'agent/inbox/spliced': {
321
+ // The durable inbox mutation (web queue-mirror contract, event-sourced):
322
+ // removals drop the projected rows at their inbox coordinates, inserted
323
+ // messages gain a pending row at their log position.
324
+ const { target, start, removedCount = 0, inserted } = event.data
325
+ const ids = view.pending[target]
326
+ const removed = ids.slice(start, start + removedCount)
327
+ const nextIds = [
328
+ ...ids.slice(0, start),
329
+ ...ids.slice(start + removedCount),
330
+ ...inserted.map(message => message.id),
331
+ ]
332
+ let entries = view.entries
333
+ if (removed.length > 0) {
334
+ const removedSet = new Set(removed)
335
+ entries = entries.filter(entry =>
336
+ !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
337
+ }
338
+ for (const message of inserted) {
339
+ entries = [...entries, {
340
+ kind: 'pending',
341
+ messageId: message.id,
342
+ target,
343
+ text: pendingText(message.content),
344
+ }]
345
+ }
346
+ return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
284
347
  }
285
348
  case 'assistant/chunk': {
286
349
  const chunk = event.data.chunk