dsh-code 0.3.0 → 0.5.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.
Files changed (46) hide show
  1. package/README.md +201 -55
  2. package/README.zh.md +204 -61
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/index.mjs +4073 -1802
  6. package/lib/startup.mjs +34 -17
  7. package/lib/types/app.d.ts +23 -22
  8. package/lib/types/commands.d.ts +2 -0
  9. package/lib/types/index.d.ts +5 -5
  10. package/lib/types/internals.d.ts +2 -0
  11. package/lib/types/kernel-panels.d.ts +23 -0
  12. package/lib/types/plugin-inventory.d.ts +11 -0
  13. package/lib/types/presets.d.ts +32 -0
  14. package/lib/types/render/export.d.ts +15 -0
  15. package/lib/types/render/inspector.d.ts +34 -0
  16. package/lib/types/render/lines.d.ts +31 -0
  17. package/lib/types/render/projection.d.ts +95 -3
  18. package/lib/types/render/status.d.ts +19 -0
  19. package/lib/types/render/text.d.ts +27 -0
  20. package/lib/types/render/tool-detail.d.ts +92 -0
  21. package/lib/types/session-directory.d.ts +54 -0
  22. package/lib/types/session-switch.d.ts +17 -0
  23. package/lib/types/skills.d.ts +2 -0
  24. package/lib/types/startup.d.ts +11 -1
  25. package/lib/types/store.d.ts +2 -0
  26. package/package.json +16 -1
  27. package/src/app.ts +1367 -277
  28. package/src/commands.ts +15 -1
  29. package/src/index.ts +373 -128
  30. package/src/internals.ts +5 -0
  31. package/src/kernel-panels.ts +254 -0
  32. package/src/plugin-inventory.ts +47 -0
  33. package/src/presets.ts +64 -0
  34. package/src/render/export.ts +81 -0
  35. package/src/render/inspector.ts +88 -0
  36. package/src/render/lines.ts +207 -0
  37. package/src/render/markdown.ts +15 -1
  38. package/src/render/projection.ts +279 -16
  39. package/src/render/status.ts +51 -1
  40. package/src/render/text.ts +107 -0
  41. package/src/render/tool-detail.ts +197 -0
  42. package/src/session-directory.ts +102 -0
  43. package/src/session-switch.ts +58 -0
  44. package/src/skills.ts +20 -7
  45. package/src/startup.ts +38 -20
  46. package/src/store.ts +8 -0
package/src/internals.ts CHANGED
@@ -11,6 +11,8 @@ import type { ReactElement } from 'react'
11
11
 
12
12
  /** A mounted terminal app instance; the runner owns unmount ordering. */
13
13
  export interface TuiMount {
14
+ /** Replace the root element while preserving Ink's single terminal owner. */
15
+ rerender(element: ReactElement): void
14
16
  /** Tear the terminal app down before flush and exit. */
15
17
  unmount(): void
16
18
  }
@@ -28,6 +30,9 @@ export const internals: {
28
30
  mount: (element: ReactElement): TuiMount => {
29
31
  const instance = render(element)
30
32
  return {
33
+ rerender(element: ReactElement): void {
34
+ instance.rerender(element)
35
+ },
31
36
  unmount(): void {
32
37
  instance.unmount()
33
38
  },
@@ -0,0 +1,254 @@
1
+ /** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
2
+
3
+ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
4
+ import { Box, Text, useInput, useStdout } from 'ink'
5
+ import type { PresetRow } from './presets.ts'
6
+ import type { PluginRow } from './plugin-inventory.ts'
7
+ import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
8
+ import { panelViewport, revealRow } from './render/inspector.ts'
9
+ import { textLines } from './render/lines.ts'
10
+ import { singleLineText, truncateColumns } from './render/text.ts'
11
+ import { TUI_RGB } from './theme.ts'
12
+
13
+ function color(rgb: readonly [number, number, number]): string {
14
+ return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`
15
+ }
16
+
17
+ interface ListFrameProps {
18
+ readonly title: string
19
+ readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
20
+ readonly cursor: number
21
+ readonly loading: boolean
22
+ readonly error?: string
23
+ readonly query: string
24
+ readonly footer: string
25
+ }
26
+
27
+ function ListFrame(props: ListFrameProps): ReactElement {
28
+ const stdout = useStdout().stdout
29
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
30
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
31
+ if (viewport.compact) {
32
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${props.title} · esc close`, viewport.contentColumns))
33
+ }
34
+ const stateRows = props.loading
35
+ ? [{ key: 'loading', text: ' loading…' }]
36
+ : props.error !== undefined
37
+ ? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
38
+ : props.rows.length === 0
39
+ ? [{ key: 'empty', text: ' no matching entries' }]
40
+ : props.rows
41
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
42
+ const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
43
+ const visible = stateRows.slice(offset, offset + bodyRows)
44
+ return createElement(
45
+ Box,
46
+ { borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
47
+ createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(props.title, viewport.contentColumns)),
48
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`search: ${props.query === '' ? 'type to filter' : props.query}`, viewport.contentColumns)),
49
+ ...visible.map((row, index) => {
50
+ const absolute = offset + index
51
+ const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
52
+ return createElement(Text, {
53
+ key: row.key,
54
+ color: selected ? color(TUI_RGB.brandBright) : row.disabled ? color(TUI_RGB.dim) : undefined,
55
+ dimColor: row.disabled,
56
+ wrap: 'truncate-end',
57
+ }, truncateColumns(`${selected ? '› ' : ' '}${row.text}`, viewport.contentColumns))
58
+ }),
59
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(props.footer, viewport.contentColumns)),
60
+ )
61
+ }
62
+
63
+ function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
64
+ if (key.backspace || key.delete) return query.slice(0, -1)
65
+ if (input.length === 1 && input >= ' ' && input !== '\x7f') return query + input
66
+ return undefined
67
+ }
68
+
69
+ export function ModePanel({ current, load, select, close }: {
70
+ current: string
71
+ load(): Promise<readonly PresetRow[]>
72
+ select(id: string): void
73
+ close(): void
74
+ }): ReactElement {
75
+ const [rows, setRows] = useState<readonly PresetRow[]>([])
76
+ const [query, setQuery] = useState('')
77
+ const [cursor, setCursor] = useState(0)
78
+ const [loading, setLoading] = useState(true)
79
+ const [error, setError] = useState<string>()
80
+ const refresh = (): void => {
81
+ setLoading(true); setError(undefined)
82
+ Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
83
+ setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
84
+ })
85
+ }
86
+ useEffect(refresh, [])
87
+ const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
88
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
89
+ useInput((input, key) => {
90
+ if (key.escape || input === 'q') return close()
91
+ if (input === 'r' && query === '') return refresh()
92
+ if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
93
+ if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
94
+ if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
95
+ const next = editQuery(query, input, key)
96
+ if (next !== undefined) { setQuery(next); setCursor(0) }
97
+ })
98
+ return createElement(ListFrame, {
99
+ title: `/mode · current ${current}`,
100
+ rows: visible.map(row => ({ key: row.id, disabled: row.broken !== undefined, text: `${row.id === current ? '●' : '○'} ${row.name ?? row.id} · ${row.description ?? row.trust}${row.broken === undefined ? '' : ` · broken: ${row.broken}`}` })),
101
+ cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
102
+ })
103
+ }
104
+
105
+ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
106
+ const [epoch, setEpoch] = useState(0)
107
+ const [query, setQuery] = useState(initialQuery)
108
+ const [cursor, setCursor] = useState(0)
109
+ const [expanded, setExpanded] = useState(false)
110
+ const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
111
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
112
+ useInput((input, key) => {
113
+ if (key.escape || input === 'q') return close()
114
+ if (input === 'r' && query === '') return setEpoch(value => value + 1)
115
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
116
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
117
+ if (key.return) return setExpanded(value => !value)
118
+ const next = editQuery(query, input, key)
119
+ if (next !== undefined) { setQuery(next); setCursor(0) }
120
+ })
121
+ return createElement(ListFrame, {
122
+ title: '/plugin · loader inspector',
123
+ rows: rows.map((row, index) => ({
124
+ key: row.entryId,
125
+ disabled: !row.enabled,
126
+ text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
127
+ })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
128
+ })
129
+ }
130
+
131
+ export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
132
+ currentCwd: string
133
+ load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
134
+ readTranscript(id: string, signal?: AbortSignal): Promise<string>
135
+ select(row: SessionRow): void
136
+ close(): void
137
+ }): ReactElement {
138
+ const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'all', sort: 'newest', currentCwd, query: '' })
139
+ const [focus, setFocus] = useState(0)
140
+ const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
141
+ const [rows, setRows] = useState<readonly SessionRow[]>([])
142
+ const [cursor, setCursor] = useState(0)
143
+ const [loading, setLoading] = useState(true)
144
+ const [error, setError] = useState<string>()
145
+ const [expanded, setExpanded] = useState<string>()
146
+ const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
147
+ const transcriptLoad = useRef<AbortController>()
148
+ useEffect(() => () => transcriptLoad.current?.abort(), [])
149
+ useEffect(() => {
150
+ const controller = new AbortController()
151
+ setLoading(true); setError(undefined)
152
+ Promise.resolve().then(() => load(options, controller.signal)).then(value => {
153
+ if (!controller.signal.aborted) { setRows(value); setLoading(false) }
154
+ }, reason => {
155
+ if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
156
+ })
157
+ return () => controller.abort()
158
+ }, [options])
159
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
160
+ const cycle = (): void => {
161
+ if (focus === 3) {
162
+ setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
163
+ return
164
+ }
165
+ setOptions(value => {
166
+ if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
167
+ if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
168
+ return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
169
+ })
170
+ }
171
+ useInput((input, key) => {
172
+ if (key.escape || input === 'q') return close()
173
+ if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
174
+ if (key.leftArrow) return cycle()
175
+ if (key.rightArrow) return cycle()
176
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
177
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
178
+ if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
179
+ if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
180
+ if (input === 'g') return setCursor(0)
181
+ if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
182
+ if (input === 'd') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
183
+ if (input === 'e' && rows[cursor] !== undefined) {
184
+ return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
185
+ }
186
+ if (input === 't' && rows[cursor] !== undefined) {
187
+ const row = rows[cursor]!
188
+ transcriptLoad.current?.abort()
189
+ setTranscript({ id: row.id })
190
+ const controller = new AbortController()
191
+ transcriptLoad.current = controller
192
+ Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
193
+ text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
194
+ reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
195
+ )
196
+ return
197
+ }
198
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
199
+ const next = editQuery(options.query, input, key)
200
+ if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
201
+ }, { isActive: transcript === undefined })
202
+ if (transcript !== undefined) {
203
+ return createElement(DocumentPanel, {
204
+ title: `transcript · ${transcript.id}`,
205
+ text: transcript.text,
206
+ error: transcript.error,
207
+ close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
208
+ })
209
+ }
210
+ const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
211
+ return createElement(ListFrame, {
212
+ title: `/resume · ${toolbar}`,
213
+ rows: rows.map(row => ({
214
+ key: row.id,
215
+ disabled: !row.resumable,
216
+ text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
217
+ })), cursor, loading, error, query: options.query,
218
+ footer: 'type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume',
219
+ })
220
+ }
221
+
222
+ function DocumentPanel({ title, text, error, close }: {
223
+ title: string
224
+ text?: string
225
+ error?: string
226
+ close(): void
227
+ }): ReactElement {
228
+ const stdout = useStdout().stdout
229
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
230
+ const [scroll, setScroll] = useState(0)
231
+ const lines = useMemo(() => text === undefined ? [] : textLines(text, viewport.contentColumns).map(line => line.segments.map(segment => segment.text).join('')), [text, viewport.contentColumns])
232
+ useInput((input, key) => {
233
+ if (key.escape || input === 'q' || input === 't') return close()
234
+ if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
235
+ if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
236
+ if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
237
+ if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
238
+ if (input === 'g') return setScroll(0)
239
+ if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
240
+ })
241
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
242
+ const body = error !== undefined
243
+ ? [`error: ${singleLineText(error)}`]
244
+ : text === undefined
245
+ ? ['loading transcript…']
246
+ : lines.slice(scroll, scroll + viewport.bodyRows)
247
+ return createElement(
248
+ Box,
249
+ { borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
250
+ createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)),
251
+ ...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
252
+ 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
+ }
@@ -0,0 +1,47 @@
1
+ /** Read-only projection of Cordis Loader entries for /plugin. */
2
+
3
+ import type { Context, FiberState } from '@deepseek-ai/cordis'
4
+
5
+ export type PluginPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unloading' | null
6
+
7
+ export interface PluginRow {
8
+ readonly entryId: string
9
+ readonly moduleName: string
10
+ readonly enabled: boolean
11
+ readonly phase: PluginPhase
12
+ }
13
+
14
+ const PHASES: Record<number, PluginPhase> = {
15
+ 0: 'pending',
16
+ 1: 'loading',
17
+ 2: 'active',
18
+ 3: 'failed',
19
+ 4: null,
20
+ 5: 'unloading',
21
+ }
22
+
23
+ interface LoaderEntry {
24
+ readonly id: string
25
+ readonly disabled: boolean
26
+ readonly options: { readonly group?: boolean; readonly name: string }
27
+ readonly fiber?: { readonly state: FiberState }
28
+ }
29
+
30
+ /** Snapshot the live Loader; group-only rows are composition containers, not plugins. */
31
+ export function listPluginRows(ctx: Context): PluginRow[] {
32
+ const loader = (ctx as unknown as { get(name: string): unknown }).get('loader') as
33
+ | { entries(): Iterable<LoaderEntry> }
34
+ | undefined
35
+ if (loader === undefined) return []
36
+ const rows: PluginRow[] = []
37
+ for (const entry of loader.entries()) {
38
+ if (entry.options.group === true) continue
39
+ rows.push({
40
+ entryId: entry.id,
41
+ moduleName: entry.options.name,
42
+ enabled: !entry.disabled,
43
+ phase: entry.fiber === undefined ? null : PHASES[entry.fiber.state] ?? null,
44
+ })
45
+ }
46
+ return rows
47
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,64 @@
1
+ /** Agent-preset policy kept independent from the Ink surface. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
5
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
6
+
7
+ /** One discoverable agent composition. */
8
+ export interface PresetRow {
9
+ readonly id: string
10
+ readonly trust: 'system' | 'user'
11
+ readonly name?: string
12
+ readonly description?: string
13
+ readonly order?: number
14
+ readonly broken?: string
15
+ }
16
+
17
+ /** Structural boundary for the optional upstream AgentPresets service. */
18
+ export interface AgentPresetsService {
19
+ readonly defaultId: string
20
+ list(): Promise<PresetRow[]>
21
+ resolve(id?: string): Promise<PresetRow>
22
+ mount(agentCtx: Context, id?: string): Promise<PresetRow>
23
+ recompose(agentCtx: Context, id: string): Promise<PresetRow>
24
+ composedPreset(agentCtx: Context): string | undefined
25
+ }
26
+
27
+ /** Read an optional Cordis service without requiring its package at build time. */
28
+ export function agentPresetsFrom(ctx: Context): AgentPresetsService | undefined {
29
+ return (ctx as unknown as { get(name: string): unknown }).get('agentPresets') as AgentPresetsService | undefined
30
+ }
31
+
32
+ /** A preset may change only before the first durable turn begins. */
33
+ export function isBlankSession(events: readonly SessionEvent[]): boolean {
34
+ return !events.some(event => event.type === 'turn/start')
35
+ }
36
+
37
+ /** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
38
+ export function resolvePreset(session: Pick<Session, 'header' | 'events'>): string {
39
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
40
+ const event = session.events[index] as unknown as { type: string; data?: { agentPreset?: string } }
41
+ if (event.type === 'agent-preset/selected' && event.data?.agentPreset !== undefined) {
42
+ return event.data.agentPreset
43
+ }
44
+ }
45
+ return session.header.agentPreset ?? 'standard'
46
+ }
47
+
48
+ /** Recompose atomically from the caller's perspective, logging only success. */
49
+ export async function switchPreset(
50
+ service: AgentPresetsService,
51
+ agent: Agent,
52
+ presetId: string,
53
+ ): Promise<PresetRow> {
54
+ if (!isBlankSession(agent.session.events)) {
55
+ throw new Error('mode is locked after the first turn; use /new <mode>')
56
+ }
57
+ const preset = await service.recompose(agent.ctx, presetId)
58
+ const writable = agent.session as unknown as { append(type: string, data: unknown): void }
59
+ writable.append('agent-preset/selected', { agentPreset: preset.id })
60
+ return preset
61
+ }
62
+
63
+ /** Minimal handle shape used by lifecycle tests without exposing Agent internals. */
64
+ export type OwnedAgent = Pick<AgentHandle, 'agent' | 'dispose'>
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Markdown export of one transcript view: the /export command's pure
3
+ * formatter. Deterministic and side-effect free — the runner owns the file
4
+ * write, so tests drive the builder with folded views directly.
5
+ *
6
+ * @module @deepseek-ai/dsh-code/render/export
7
+ */
8
+
9
+ import { assertNever } from '@deepseek-ai/dsh-llm'
10
+ import type { TranscriptView } from './projection.ts'
11
+
12
+ /**
13
+ * Render the transcript as a standalone markdown document.
14
+ * @param view - the folded transcript view to export.
15
+ * @param sessionId - the full session identity for the header.
16
+ * @returns the complete markdown text.
17
+ */
18
+ export function buildExportMarkdown(view: TranscriptView, sessionId: string): string {
19
+ const out: string[] = [
20
+ view.title === ''
21
+ ? `# dsh session ${sessionId}`
22
+ : `# ${view.title}`,
23
+ `> session ${sessionId}`,
24
+ '',
25
+ ]
26
+ for (const entry of view.entries) {
27
+ switch (entry.kind) {
28
+ case 'user':
29
+ if (entry.notice) {
30
+ out.push(`> ⤷ context: ${entry.text}`, '')
31
+ } else {
32
+ out.push('## user', '', entry.text, '')
33
+ }
34
+ break
35
+ case 'assistant':
36
+ if (entry.reasoning !== '') {
37
+ out.push('<details><summary>thinking</summary>', '', entry.reasoning, '', '</details>', '')
38
+ }
39
+ out.push('## assistant', '', entry.text, '')
40
+ break
41
+ case 'tool':
42
+ out.push(`### tool \`${entry.name}\``, '')
43
+ if (entry.preview !== '') out.push(`- args: ${entry.preview}`)
44
+ if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
45
+ out.push('')
46
+ break
47
+ case 'command':
48
+ out.push(`### /${entry.name}${entry.args === '' ? '' : ` ${entry.args}`}`, '')
49
+ if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
50
+ out.push('')
51
+ break
52
+ case 'error':
53
+ out.push(`> ⨯ ${entry.text}`, '')
54
+ break
55
+ case 'turn-marker':
56
+ out.push(`> ${entry.text}`, '')
57
+ break
58
+ case 'compaction':
59
+ out.push(entry.ok
60
+ ? `> compacted ~${entry.tokens} tokens`
61
+ : `> compaction failed: ${entry.error}`, '')
62
+ break
63
+ case 'retry':
64
+ out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, '')
65
+ break
66
+ case 'files':
67
+ out.push(`> files changed: ${entry.paths.join(', ')}`, '')
68
+ break
69
+ default:
70
+ assertNever(entry, 'transcript entry kind')
71
+ }
72
+ }
73
+ if (view.streaming !== '') out.push('## assistant (streaming)', '', view.streaming, '')
74
+ const { stats } = view
75
+ out.push('---', '')
76
+ out.push(`- model: ${view.model === '' ? '(none yet)' : view.model}`)
77
+ out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`)
78
+ out.push(`- tokens: ↑${stats.usage.inputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens}`)
79
+ out.push(`- todos: ${view.todos.length}`)
80
+ return out.join('\n')
81
+ }
@@ -0,0 +1,88 @@
1
+ /** Pure viewport, selection, and scrolling rules for exclusive TUI panels. */
2
+
3
+ /** Terminal-space allocation for the inspector's one dynamic screen. */
4
+ export interface InspectorViewport {
5
+ /** Maximum dynamic rows, kept strictly below the terminal height. */
6
+ maxHeight: number
7
+ /** Rows available to the selected entry after border, title, and footer. */
8
+ bodyRows: number
9
+ /** Optional blank rows separating title/body/footer on roomy terminals. */
10
+ gapRows: 0 | 2
11
+ /** Columns available inside the horizontal border and padding. */
12
+ contentColumns: number
13
+ /** Tiny terminals use a borderless one-line close hint. */
14
+ compact: boolean
15
+ }
16
+
17
+ /** Composer/status chrome plus one optional, fixed-height local notice row. */
18
+ const INSPECTOR_CHROME_ROWS = 5
19
+
20
+ /** One transcript-to-composer gutter, collapsed on short terminals. */
21
+ export function layoutGutterRows(rows: number): 0 | 1 {
22
+ return Math.max(1, Math.floor(rows)) >= 14 ? 1 : 0
23
+ }
24
+
25
+ /**
26
+ * Keep the inspector plus its persistent status/composer chrome below
27
+ * `stdout.rows`: at equality Ink clears the terminal and rewrites all
28
+ * accumulated `<Static>` output on every frame.
29
+ */
30
+ export function panelViewport(columns: number, rows: number): InspectorViewport {
31
+ const safeColumns = Math.max(1, Math.floor(columns))
32
+ const safeRows = Math.max(1, Math.floor(rows))
33
+ // Two spare rows cover Ink's first-frame transition from existing Static
34
+ // scrollback into a tall dynamic panel. A one-row margin is insufficient:
35
+ // the transition can still take the full-terminal rewrite path at rows - 1.
36
+ const maxHeight = Math.max(0, Math.min(
37
+ safeRows - 2 - INSPECTOR_CHROME_ROWS - layoutGutterRows(safeRows),
38
+ Math.floor(safeRows / 2),
39
+ ))
40
+ const compact = maxHeight < 5 || safeColumns < 8
41
+ const gapRows = !compact && maxHeight >= 7 ? 2 : 0
42
+ return {
43
+ maxHeight,
44
+ bodyRows: compact ? 0 : maxHeight - 4 - gapRows,
45
+ gapRows,
46
+ contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
47
+ compact,
48
+ }
49
+ }
50
+
51
+ /** Backward-compatible name for the Ctrl+O-specific caller and tests. */
52
+ export function inspectorViewport(columns: number, rows: number): InspectorViewport {
53
+ return panelViewport(columns, rows)
54
+ }
55
+
56
+ /** Clamp a first-visible row to the range representable by one viewport. */
57
+ export function clampScroll(offset: number, totalRows: number, visibleRows: number): number {
58
+ const total = Math.max(0, Math.floor(totalRows))
59
+ const size = Math.max(0, Math.floor(visibleRows))
60
+ const last = Math.max(0, total - size)
61
+ return Math.max(0, Math.min(Math.floor(offset), last))
62
+ }
63
+
64
+ /** Move a viewport by a signed row delta without escaping its content. */
65
+ export function moveScroll(offset: number, delta: number, totalRows: number, visibleRows: number): number {
66
+ return clampScroll(offset + delta, totalRows, visibleRows)
67
+ }
68
+
69
+ /** Keep one focused row visible while preserving the current window when possible. */
70
+ export function revealRow(offset: number, row: number, totalRows: number, visibleRows: number): number {
71
+ const size = Math.max(1, Math.floor(visibleRows))
72
+ const target = Math.max(0, Math.min(Math.floor(row), Math.max(0, totalRows - 1)))
73
+ if (target < offset) return clampScroll(target, totalRows, size)
74
+ if (target >= offset + size) return clampScroll(target - size + 1, totalRows, size)
75
+ return clampScroll(offset, totalRows, size)
76
+ }
77
+
78
+ /** Center a selected list row where possible, clamped at both ends. */
79
+ export function selectionWindow(cursor: number, totalRows: number, visibleRows: number): number {
80
+ return clampScroll(cursor - Math.floor(Math.max(1, visibleRows) / 2), totalRows, visibleRows)
81
+ }
82
+
83
+ /** Follow appended history only while the inspector cursor was at the tail. */
84
+ export function followInspectorCursor(cursor: number, previousLength: number, nextLength: number): number {
85
+ const nextLast = Math.max(0, nextLength - 1)
86
+ if (cursor >= Math.max(0, previousLength - 1)) return nextLast
87
+ return Math.min(cursor, nextLast)
88
+ }