dsh-code 0.4.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 -61
- package/README.zh.md +204 -65
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +1108 -250
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +19 -1
- 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/inspector.d.ts +4 -0
- package/lib/types/render/status.d.ts +2 -0
- package/lib/types/render/text.d.ts +9 -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/package.json +6 -1
- package/src/app.ts +311 -75
- package/src/commands.ts +15 -1
- package/src/index.ts +332 -133
- 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/inspector.ts +13 -4
- package/src/render/markdown.ts +15 -1
- package/src/render/status.ts +3 -0
- package/src/render/text.ts +34 -6
- 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/pictures/1.png +0 -0
|
@@ -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'>
|
package/src/render/inspector.ts
CHANGED
|
@@ -6,14 +6,21 @@ export interface InspectorViewport {
|
|
|
6
6
|
maxHeight: number
|
|
7
7
|
/** Rows available to the selected entry after border, title, and footer. */
|
|
8
8
|
bodyRows: number
|
|
9
|
+
/** Optional blank rows separating title/body/footer on roomy terminals. */
|
|
10
|
+
gapRows: 0 | 2
|
|
9
11
|
/** Columns available inside the horizontal border and padding. */
|
|
10
12
|
contentColumns: number
|
|
11
13
|
/** Tiny terminals use a borderless one-line close hint. */
|
|
12
14
|
compact: boolean
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
/**
|
|
16
|
-
const INSPECTOR_CHROME_ROWS =
|
|
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
|
+
}
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* Keep the inspector plus its persistent status/composer chrome below
|
|
@@ -27,13 +34,15 @@ export function panelViewport(columns: number, rows: number): InspectorViewport
|
|
|
27
34
|
// scrollback into a tall dynamic panel. A one-row margin is insufficient:
|
|
28
35
|
// the transition can still take the full-terminal rewrite path at rows - 1.
|
|
29
36
|
const maxHeight = Math.max(0, Math.min(
|
|
30
|
-
safeRows - 2 - INSPECTOR_CHROME_ROWS,
|
|
37
|
+
safeRows - 2 - INSPECTOR_CHROME_ROWS - layoutGutterRows(safeRows),
|
|
31
38
|
Math.floor(safeRows / 2),
|
|
32
39
|
))
|
|
33
40
|
const compact = maxHeight < 5 || safeColumns < 8
|
|
41
|
+
const gapRows = !compact && maxHeight >= 7 ? 2 : 0
|
|
34
42
|
return {
|
|
35
43
|
maxHeight,
|
|
36
|
-
bodyRows: compact ? 0 : maxHeight - 4,
|
|
44
|
+
bodyRows: compact ? 0 : maxHeight - 4 - gapRows,
|
|
45
|
+
gapRows,
|
|
37
46
|
contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
|
|
38
47
|
compact,
|
|
39
48
|
}
|
package/src/render/markdown.ts
CHANGED
|
@@ -166,11 +166,18 @@ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
|
|
|
166
166
|
/** Render markdown text into styled lines of at most `width` columns. */
|
|
167
167
|
export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
168
168
|
const lines: MdLine[] = []
|
|
169
|
+
let separatorPending = false
|
|
169
170
|
const push = (segments: readonly MdSegment[]): void => {
|
|
170
171
|
for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
|
|
171
172
|
lines.push({ segments: merge(wrapped) })
|
|
172
173
|
}
|
|
173
174
|
}
|
|
175
|
+
const startBlock = (): void => {
|
|
176
|
+
if (separatorPending && lines.length > 0 && lines.at(-1)?.segments.length !== 0) {
|
|
177
|
+
lines.push({ segments: [] })
|
|
178
|
+
}
|
|
179
|
+
separatorPending = false
|
|
180
|
+
}
|
|
174
181
|
const raw = text.replaceAll('\r', '')
|
|
175
182
|
const source = raw.split('\n')
|
|
176
183
|
let index = 0
|
|
@@ -178,6 +185,14 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
|
178
185
|
const line = source[index] ?? ''
|
|
179
186
|
index += 1
|
|
180
187
|
|
|
188
|
+
// Preserve one deliberate row between source blocks. Repeated blank
|
|
189
|
+
// lines collapse, and leading/trailing whitespace never grows output.
|
|
190
|
+
if (line.trim() === '') {
|
|
191
|
+
separatorPending = lines.length > 0
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
startBlock()
|
|
195
|
+
|
|
181
196
|
// Fenced code block: verbatim lines in code style, language label first.
|
|
182
197
|
const fence = FENCE.exec(line)
|
|
183
198
|
if (fence !== null) {
|
|
@@ -191,7 +206,6 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
|
191
206
|
continue
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
if (line.trim() === '') continue
|
|
195
209
|
if (RULE.test(line.trim())) {
|
|
196
210
|
push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
|
|
197
211
|
continue
|
package/src/render/status.ts
CHANGED
|
@@ -63,6 +63,8 @@ export function cacheHitPercent(usage: TranscriptStats['usage']): number | null
|
|
|
63
63
|
export interface StatusFacts {
|
|
64
64
|
/** `provider/model` selection serving this session. */
|
|
65
65
|
model: string
|
|
66
|
+
/** Agent preset composing this session. */
|
|
67
|
+
mode?: string
|
|
66
68
|
/** Working-directory basename the session serves. */
|
|
67
69
|
cwd: string
|
|
68
70
|
/** Git branch name, empty outside a repository or on a detached HEAD file. */
|
|
@@ -96,6 +98,7 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
|
|
|
96
98
|
facts.plan ? '⧉ plan' : undefined,
|
|
97
99
|
].filter(part => part !== undefined && part !== '')
|
|
98
100
|
if (identity.length > 0) groups.push(identity.join(' · '))
|
|
101
|
+
if (facts.mode !== undefined && facts.mode !== '') groups.push(`mode ${facts.mode}`)
|
|
99
102
|
if (stats.turns > 0 || stats.steps > 0) {
|
|
100
103
|
groups.push(`T${stats.turns} · S${stats.steps}`)
|
|
101
104
|
const durations: string[] = []
|
package/src/render/text.ts
CHANGED
|
@@ -23,12 +23,9 @@ export function displayText(text: string): string {
|
|
|
23
23
|
return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
text: string
|
|
30
|
-
/** Whether content before the returned suffix was omitted. */
|
|
31
|
-
truncated: boolean
|
|
26
|
+
/** Collapse external text to one terminal-safe logical row. */
|
|
27
|
+
export function singleLineText(text: string): string {
|
|
28
|
+
return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
/** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
|
|
@@ -40,6 +37,37 @@ function cellWidth(text: string): number {
|
|
|
40
37
|
return columns
|
|
41
38
|
}
|
|
42
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Truncate one display-safe row without ever exceeding its physical-column
|
|
42
|
+
* budget. The ellipsis is included inside the budget, matching Codex's popup
|
|
43
|
+
* truncation contract; the previous app-local helper appended it after the
|
|
44
|
+
* row was already full and could force an extra terminal wrap.
|
|
45
|
+
*/
|
|
46
|
+
export function truncateColumns(text: string, columns: number): string {
|
|
47
|
+
const limit = Math.max(0, Math.floor(columns))
|
|
48
|
+
if (limit === 0) return ''
|
|
49
|
+
if (cellWidth(text) <= limit) return text
|
|
50
|
+
|
|
51
|
+
const contentLimit = limit - 1
|
|
52
|
+
let used = 0
|
|
53
|
+
let result = ''
|
|
54
|
+
for (const char of text) {
|
|
55
|
+
const width = cellWidth(char)
|
|
56
|
+
if (used + width > contentLimit) break
|
|
57
|
+
result += char
|
|
58
|
+
used += width
|
|
59
|
+
}
|
|
60
|
+
return `${result}…`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A display-safe suffix bounded by terminal rows and columns. */
|
|
64
|
+
export interface DisplayTail {
|
|
65
|
+
/** Sanitized suffix suitable for direct terminal rendering. */
|
|
66
|
+
text: string
|
|
67
|
+
/** Whether content before the returned suffix was omitted. */
|
|
68
|
+
truncated: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
/** Read one Unicode character immediately before `end`. */
|
|
44
72
|
function previousCharacter(text: string, end: number): { char: string; start: number } {
|
|
45
73
|
const last = text.charCodeAt(end - 1)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Lightweight session-directory projection for the /resume picker. */
|
|
2
|
+
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
|
+
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
5
|
+
|
|
6
|
+
export interface SessionRecord {
|
|
7
|
+
readonly header: SessionHeader
|
|
8
|
+
readonly live: boolean
|
|
9
|
+
readonly persisted: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TitleObservationResult {
|
|
13
|
+
readonly sessionId: string
|
|
14
|
+
readonly status: 'fulfilled' | 'rejected'
|
|
15
|
+
readonly value?: { readonly title?: { readonly title?: string; readonly text?: string } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionLogSnapshot {
|
|
19
|
+
readonly session: SessionHeader
|
|
20
|
+
readonly events: SessionEvent[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Structural upstream SessionQuery surface used by the TUI. */
|
|
24
|
+
export interface SessionQueryService {
|
|
25
|
+
listSessions(signal?: AbortSignal): Promise<SessionRecord[]>
|
|
26
|
+
readTitleSnapshots(ids: readonly string[], signal?: AbortSignal): Promise<TitleObservationResult[]>
|
|
27
|
+
readSession(id: string, signal?: AbortSignal): Promise<SessionLogSnapshot>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SessionScope = 'roots' | 'all'
|
|
31
|
+
export type CwdScope = 'all' | 'current'
|
|
32
|
+
export type SessionSort = 'newest' | 'oldest'
|
|
33
|
+
|
|
34
|
+
export interface SessionDirectoryOptions {
|
|
35
|
+
readonly sessions: SessionScope
|
|
36
|
+
readonly cwd: CwdScope
|
|
37
|
+
readonly sort: SessionSort
|
|
38
|
+
readonly currentCwd: string
|
|
39
|
+
readonly query: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionRow {
|
|
43
|
+
readonly id: string
|
|
44
|
+
readonly createdAt: number
|
|
45
|
+
readonly cwd: string
|
|
46
|
+
readonly workspace: string
|
|
47
|
+
readonly parent?: string
|
|
48
|
+
readonly subagent: boolean
|
|
49
|
+
readonly resumable: boolean
|
|
50
|
+
readonly live: boolean
|
|
51
|
+
readonly persisted: boolean
|
|
52
|
+
readonly preset: string
|
|
53
|
+
readonly title?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function samePath(left: string | undefined, right: string): boolean {
|
|
57
|
+
if (left === undefined) return false
|
|
58
|
+
return resolve(left).toLowerCase() === resolve(right).toLowerCase()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Filter/sort header-only records. No session log is loaded here. */
|
|
62
|
+
export function projectSessionRows(records: readonly SessionRecord[], options: SessionDirectoryOptions): SessionRow[] {
|
|
63
|
+
const needle = options.query.trim().toLowerCase()
|
|
64
|
+
return records
|
|
65
|
+
.filter(record => options.sessions === 'all'
|
|
66
|
+
|| (record.header.parentSession === undefined && record.header.origin !== 'subagent'))
|
|
67
|
+
.filter(record => options.cwd === 'all' || samePath(record.header.cwd, options.currentCwd))
|
|
68
|
+
.map(record => {
|
|
69
|
+
const cwd = record.header.cwd ?? ''
|
|
70
|
+
const subagent = record.header.origin === 'subagent' || record.header.parentSession !== undefined
|
|
71
|
+
return {
|
|
72
|
+
id: record.header.id,
|
|
73
|
+
createdAt: record.header.createdAt,
|
|
74
|
+
cwd,
|
|
75
|
+
workspace: cwd === '' ? '(no workspace)' : basename(cwd),
|
|
76
|
+
parent: record.header.parentSession,
|
|
77
|
+
subagent,
|
|
78
|
+
resumable: !subagent,
|
|
79
|
+
live: record.live,
|
|
80
|
+
persisted: record.persisted,
|
|
81
|
+
preset: record.header.agentPreset ?? 'standard',
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
|
|
85
|
+
.sort((left, right) => options.sort === 'newest'
|
|
86
|
+
? right.createdAt - left.createdAt
|
|
87
|
+
: left.createdAt - right.createdAt)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Merge page-local title observations without disturbing directory order. */
|
|
91
|
+
export function mergeSessionTitles(
|
|
92
|
+
rows: readonly SessionRow[],
|
|
93
|
+
observations: readonly TitleObservationResult[],
|
|
94
|
+
): SessionRow[] {
|
|
95
|
+
const titles = new Map<string, string>()
|
|
96
|
+
for (const observation of observations) {
|
|
97
|
+
if (observation.status !== 'fulfilled') continue
|
|
98
|
+
const title = observation.value?.title?.title ?? observation.value?.title?.text
|
|
99
|
+
if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
|
|
100
|
+
}
|
|
101
|
+
return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row)
|
|
102
|
+
}
|