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.
- package/README.md +201 -55
- package/README.zh.md +204 -61
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +4073 -1802
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +23 -22
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +34 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +19 -0
- package/lib/types/render/text.d.ts +27 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/lib/types/store.d.ts +2 -0
- package/package.json +16 -1
- package/src/app.ts +1367 -277
- package/src/commands.ts +15 -1
- package/src/index.ts +373 -128
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +254 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +88 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +15 -1
- package/src/render/projection.ts +279 -16
- package/src/render/status.ts +51 -1
- package/src/render/text.ts +107 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- 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
|
+
}
|