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.
- package/README.en.md +217 -0
- package/README.md +147 -138
- package/lib/devtools-CdTl3MNy.mjs +3643 -0
- package/lib/index.mjs +26428 -492
- package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
- package/lib/types/app.d.ts +10 -0
- package/lib/types/history.d.ts +79 -0
- package/lib/types/kernel-panels.d.ts +25 -0
- package/lib/types/render/animations.d.ts +10 -1
- package/lib/types/render/inspector.d.ts +2 -0
- package/lib/types/render/projection.d.ts +21 -1
- package/lib/types/render/status.d.ts +129 -14
- package/package.json +117 -117
- package/src/app.ts +2543 -2205
- package/src/history.ts +136 -0
- package/src/index.ts +248 -52
- package/src/kernel-panels.ts +168 -3
- package/src/render/animations.ts +14 -1
- package/src/render/export.ts +4 -0
- package/src/render/inspector.ts +12 -3
- package/src/render/lines.ts +21 -10
- package/src/render/projection.ts +71 -8
- package/src/render/status.ts +520 -66
- package/src/whale-glyph.ts +23 -23
- package/README.zh.md +0 -204
package/src/kernel-panels.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/src/render/animations.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/render/export.ts
CHANGED
|
@@ -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
|
}
|
package/src/render/inspector.ts
CHANGED
|
@@ -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
|
|
18
|
-
const INSPECTOR_CHROME_ROWS =
|
|
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,
|
|
54
|
+
contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, outerColumns - 4),
|
|
55
|
+
outerColumns,
|
|
47
56
|
compact,
|
|
48
57
|
}
|
|
49
58
|
}
|
package/src/render/lines.ts
CHANGED
|
@@ -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 '
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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'
|
package/src/render/projection.ts
CHANGED
|
@@ -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*,
|
|
14
|
-
// permission/preset, sandbox/mode, session/title) into the union
|
|
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: [...
|
|
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: [...
|
|
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
|