dsh-code 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +217 -0
- package/README.md +216 -67
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/devtools-CdTl3MNy.mjs +3643 -0
- package/lib/index.mjs +27467 -673
- package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +29 -1
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/history.d.ts +79 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +48 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/animations.d.ts +10 -1
- package/lib/types/render/inspector.d.ts +6 -0
- package/lib/types/render/projection.d.ts +21 -1
- package/lib/types/render/status.d.ts +131 -14
- 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 +117 -112
- package/src/app.ts +2543 -1969
- package/src/commands.ts +15 -1
- package/src/history.ts +136 -0
- package/src/index.ts +550 -155
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +419 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/animations.ts +14 -1
- package/src/render/export.ts +4 -0
- package/src/render/inspector.ts +23 -5
- package/src/render/lines.ts +21 -10
- package/src/render/markdown.ts +15 -1
- package/src/render/projection.ts +71 -8
- package/src/render/status.ts +522 -65
- 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/whale-glyph.ts +23 -23
- package/README.zh.md +0 -65
- package/src/pictures/1.png +0 -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,419 @@
|
|
|
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 { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
|
|
11
|
+
import { displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
12
|
+
import { TUI_RGB } from './theme.ts'
|
|
13
|
+
|
|
14
|
+
function color(rgb: readonly [number, number, number]): string {
|
|
15
|
+
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ListFrameProps {
|
|
19
|
+
readonly title: string
|
|
20
|
+
readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
|
|
21
|
+
readonly cursor: number
|
|
22
|
+
readonly loading: boolean
|
|
23
|
+
readonly error?: string
|
|
24
|
+
readonly query: string
|
|
25
|
+
readonly footer: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ListFrame(props: ListFrameProps): ReactElement {
|
|
29
|
+
const stdout = useStdout().stdout
|
|
30
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
31
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
32
|
+
if (viewport.compact) {
|
|
33
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${props.title} · esc close`, viewport.contentColumns))
|
|
34
|
+
}
|
|
35
|
+
const stateRows = props.loading
|
|
36
|
+
? [{ key: 'loading', text: ' loading…' }]
|
|
37
|
+
: props.error !== undefined
|
|
38
|
+
? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
|
|
39
|
+
: props.rows.length === 0
|
|
40
|
+
? [{ key: 'empty', text: ' no matching entries' }]
|
|
41
|
+
: props.rows
|
|
42
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
43
|
+
const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
|
|
44
|
+
const visible = stateRows.slice(offset, offset + bodyRows)
|
|
45
|
+
return createElement(
|
|
46
|
+
Box,
|
|
47
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
|
|
48
|
+
createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(props.title, viewport.contentColumns)),
|
|
49
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`search: ${props.query === '' ? 'type to filter' : props.query}`, viewport.contentColumns)),
|
|
50
|
+
...visible.map((row, index) => {
|
|
51
|
+
const absolute = offset + index
|
|
52
|
+
const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
|
|
53
|
+
return createElement(Text, {
|
|
54
|
+
key: row.key,
|
|
55
|
+
color: selected ? color(TUI_RGB.brandBright) : row.disabled ? color(TUI_RGB.dim) : undefined,
|
|
56
|
+
dimColor: row.disabled,
|
|
57
|
+
wrap: 'truncate-end',
|
|
58
|
+
}, truncateColumns(`${selected ? '› ' : ' '}${row.text}`, viewport.contentColumns))
|
|
59
|
+
}),
|
|
60
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(props.footer, viewport.contentColumns)),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
|
|
65
|
+
if (key.backspace || key.delete) return query.slice(0, -1)
|
|
66
|
+
if (input.length === 1 && input >= ' ' && input !== '\x7f') return query + input
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ModePanel({ current, load, select, close }: {
|
|
71
|
+
current: string
|
|
72
|
+
load(): Promise<readonly PresetRow[]>
|
|
73
|
+
select(id: string): void
|
|
74
|
+
close(): void
|
|
75
|
+
}): ReactElement {
|
|
76
|
+
const [rows, setRows] = useState<readonly PresetRow[]>([])
|
|
77
|
+
const [query, setQuery] = useState('')
|
|
78
|
+
const [cursor, setCursor] = useState(0)
|
|
79
|
+
const [loading, setLoading] = useState(true)
|
|
80
|
+
const [error, setError] = useState<string>()
|
|
81
|
+
const refresh = (): void => {
|
|
82
|
+
setLoading(true); setError(undefined)
|
|
83
|
+
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
|
|
84
|
+
setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
useEffect(refresh, [])
|
|
88
|
+
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
|
|
89
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
|
|
90
|
+
useInput((input, key) => {
|
|
91
|
+
if (key.escape || input === 'q') return close()
|
|
92
|
+
if (input === 'r' && query === '') return refresh()
|
|
93
|
+
if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
|
|
94
|
+
if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
|
|
95
|
+
if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
|
|
96
|
+
const next = editQuery(query, input, key)
|
|
97
|
+
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
98
|
+
})
|
|
99
|
+
return createElement(ListFrame, {
|
|
100
|
+
title: `/mode · current ${current}`,
|
|
101
|
+
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}`}` })),
|
|
102
|
+
cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
|
|
107
|
+
const [epoch, setEpoch] = useState(0)
|
|
108
|
+
const [query, setQuery] = useState(initialQuery)
|
|
109
|
+
const [cursor, setCursor] = useState(0)
|
|
110
|
+
const [expanded, setExpanded] = useState(false)
|
|
111
|
+
const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
|
|
112
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
113
|
+
useInput((input, key) => {
|
|
114
|
+
if (key.escape || input === 'q') return close()
|
|
115
|
+
if (input === 'r' && query === '') return setEpoch(value => value + 1)
|
|
116
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
|
|
117
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
|
|
118
|
+
if (key.return) return setExpanded(value => !value)
|
|
119
|
+
const next = editQuery(query, input, key)
|
|
120
|
+
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
121
|
+
})
|
|
122
|
+
return createElement(ListFrame, {
|
|
123
|
+
title: '/plugin · loader inspector',
|
|
124
|
+
rows: rows.map((row, index) => ({
|
|
125
|
+
key: row.entryId,
|
|
126
|
+
disabled: !row.enabled,
|
|
127
|
+
text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
|
|
128
|
+
})), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
|
|
133
|
+
currentCwd: string
|
|
134
|
+
load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
135
|
+
readTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
136
|
+
select(row: SessionRow): void
|
|
137
|
+
close(): void
|
|
138
|
+
}): ReactElement {
|
|
139
|
+
const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'all', sort: 'newest', currentCwd, query: '' })
|
|
140
|
+
const [focus, setFocus] = useState(0)
|
|
141
|
+
const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
|
|
142
|
+
const [rows, setRows] = useState<readonly SessionRow[]>([])
|
|
143
|
+
const [cursor, setCursor] = useState(0)
|
|
144
|
+
const [loading, setLoading] = useState(true)
|
|
145
|
+
const [error, setError] = useState<string>()
|
|
146
|
+
const [expanded, setExpanded] = useState<string>()
|
|
147
|
+
const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
|
|
148
|
+
const transcriptLoad = useRef<AbortController>()
|
|
149
|
+
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
setLoading(true); setError(undefined)
|
|
153
|
+
Promise.resolve().then(() => load(options, controller.signal)).then(value => {
|
|
154
|
+
if (!controller.signal.aborted) { setRows(value); setLoading(false) }
|
|
155
|
+
}, reason => {
|
|
156
|
+
if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
|
|
157
|
+
})
|
|
158
|
+
return () => controller.abort()
|
|
159
|
+
}, [options])
|
|
160
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
161
|
+
const cycle = (): void => {
|
|
162
|
+
if (focus === 3) {
|
|
163
|
+
setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
setOptions(value => {
|
|
167
|
+
if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
|
|
168
|
+
if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
|
|
169
|
+
return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
useInput((input, key) => {
|
|
173
|
+
if (key.escape || input === 'q') return close()
|
|
174
|
+
if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
|
|
175
|
+
if (key.leftArrow) return cycle()
|
|
176
|
+
if (key.rightArrow) return cycle()
|
|
177
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
|
|
178
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
|
|
179
|
+
if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
|
|
180
|
+
if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
|
|
181
|
+
if (input === 'g') return setCursor(0)
|
|
182
|
+
if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
|
|
183
|
+
if (input === 'd') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
|
|
184
|
+
if (input === 'e' && rows[cursor] !== undefined) {
|
|
185
|
+
return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
|
|
186
|
+
}
|
|
187
|
+
if (input === 't' && rows[cursor] !== undefined) {
|
|
188
|
+
const row = rows[cursor]!
|
|
189
|
+
transcriptLoad.current?.abort()
|
|
190
|
+
setTranscript({ id: row.id })
|
|
191
|
+
const controller = new AbortController()
|
|
192
|
+
transcriptLoad.current = controller
|
|
193
|
+
Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
|
|
194
|
+
text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
|
|
195
|
+
reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
|
|
200
|
+
const next = editQuery(options.query, input, key)
|
|
201
|
+
if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
|
|
202
|
+
}, { isActive: transcript === undefined })
|
|
203
|
+
if (transcript !== undefined) {
|
|
204
|
+
return createElement(DocumentPanel, {
|
|
205
|
+
title: `transcript · ${transcript.id}`,
|
|
206
|
+
text: transcript.text,
|
|
207
|
+
error: transcript.error,
|
|
208
|
+
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
|
|
212
|
+
return createElement(ListFrame, {
|
|
213
|
+
title: `/resume · ${toolbar}`,
|
|
214
|
+
rows: rows.map(row => ({
|
|
215
|
+
key: row.id,
|
|
216
|
+
disabled: !row.resumable,
|
|
217
|
+
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}`}` : ''}`,
|
|
218
|
+
})), cursor, loading, error, query: options.query,
|
|
219
|
+
footer: 'type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume',
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function DocumentPanel({ title, text, error, close }: {
|
|
224
|
+
title: string
|
|
225
|
+
text?: string
|
|
226
|
+
error?: string
|
|
227
|
+
close(): void
|
|
228
|
+
}): ReactElement {
|
|
229
|
+
const stdout = useStdout().stdout
|
|
230
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
231
|
+
const [scroll, setScroll] = useState(0)
|
|
232
|
+
const lines = useMemo(() => text === undefined ? [] : textLines(text, viewport.contentColumns).map(line => line.segments.map(segment => segment.text).join('')), [text, viewport.contentColumns])
|
|
233
|
+
useInput((input, key) => {
|
|
234
|
+
if (key.escape || input === 'q' || input === 't') return close()
|
|
235
|
+
if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
|
|
236
|
+
if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
|
|
237
|
+
if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
|
|
238
|
+
if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
|
|
239
|
+
if (input === 'g') return setScroll(0)
|
|
240
|
+
if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
241
|
+
})
|
|
242
|
+
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
|
|
243
|
+
const body = error !== undefined
|
|
244
|
+
? [`error: ${singleLineText(error)}`]
|
|
245
|
+
: text === undefined
|
|
246
|
+
? ['loading transcript…']
|
|
247
|
+
: lines.slice(scroll, scroll + viewport.bodyRows)
|
|
248
|
+
return createElement(
|
|
249
|
+
Box,
|
|
250
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
|
|
251
|
+
createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)),
|
|
252
|
+
...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
|
|
253
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)),
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The /history recall panel (Codex composer-history search, bounded): one
|
|
259
|
+
* query line over the newest-first recall space, filtered by substring, with
|
|
260
|
+
* arrow selection and enter to fill the composer. Editing the query restarts
|
|
261
|
+
* from the newest match; Esc closes without touching the draft.
|
|
262
|
+
*/
|
|
263
|
+
export function HistoryPanel({ entries, fill, close }: {
|
|
264
|
+
/** Newest-first recall entries (persistent + in-session, deduped). */
|
|
265
|
+
entries: readonly string[]
|
|
266
|
+
/** Accept one entry: its text plus its recall-space index (browsing resumes there). */
|
|
267
|
+
fill(text: string, index: number): void
|
|
268
|
+
close(): void
|
|
269
|
+
}): ReactElement {
|
|
270
|
+
const [query, setQuery] = useState('')
|
|
271
|
+
const [cursor, setCursor] = useState(0)
|
|
272
|
+
const matches = query === ''
|
|
273
|
+
? entries
|
|
274
|
+
: entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
|
|
275
|
+
useInput((input, key) => {
|
|
276
|
+
if (key.escape) return close()
|
|
277
|
+
if (key.return) {
|
|
278
|
+
const entry = matches[cursor]
|
|
279
|
+
if (entry !== undefined) fill(entry, entries.indexOf(entry))
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
283
|
+
if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
|
|
284
|
+
if (input === 'g') return setCursor(0)
|
|
285
|
+
if (input === 'G') return setCursor(matches.length - 1)
|
|
286
|
+
if (key.backspace) {
|
|
287
|
+
setQuery(current => current.slice(0, -1))
|
|
288
|
+
setCursor(0)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
|
|
292
|
+
setQuery(current => (current + input).slice(0, 120))
|
|
293
|
+
setCursor(0)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
const stdout = useStdout().stdout
|
|
297
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
298
|
+
if (viewport.compact) {
|
|
299
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
|
|
300
|
+
}
|
|
301
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
302
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
303
|
+
const offset = revealRow(0, cursor, matches.length, bodyRows)
|
|
304
|
+
const visible = matches.slice(offset, offset + bodyRows)
|
|
305
|
+
const header = query === ''
|
|
306
|
+
? `/history · ${entries.length} prompts · type to filter`
|
|
307
|
+
: `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
|
|
308
|
+
return createElement(
|
|
309
|
+
Box,
|
|
310
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
|
|
311
|
+
createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
312
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
|
|
313
|
+
...(visible.length === 0
|
|
314
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
|
|
315
|
+
: visible.map((entry, index) => {
|
|
316
|
+
const absolute = offset + index
|
|
317
|
+
const selected = absolute === cursor
|
|
318
|
+
return createElement(
|
|
319
|
+
Text,
|
|
320
|
+
{
|
|
321
|
+
key: `history-${absolute}`,
|
|
322
|
+
color: selected ? color(TUI_RGB.brandBright) : undefined,
|
|
323
|
+
wrap: 'truncate-end',
|
|
324
|
+
},
|
|
325
|
+
truncateColumns((selected ? '› ' : ' ') + displayText(entry), viewport.contentColumns),
|
|
326
|
+
)
|
|
327
|
+
})),
|
|
328
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The /statusline picker (the Codex setup-view contract): one bounded list
|
|
334
|
+
* of every status item with its enabled mark, arrow reordering, and a
|
|
335
|
+
* live preview — the real status line under the composer updates as you
|
|
336
|
+
* edit, so the panel itself carries no duplicate preview row.
|
|
337
|
+
*/
|
|
338
|
+
export function StatuslinePanel({ enabled, change, close }: {
|
|
339
|
+
enabled: readonly StatusItemId[]
|
|
340
|
+
change(items: readonly StatusItemId[]): void
|
|
341
|
+
close(): void
|
|
342
|
+
}): ReactElement {
|
|
343
|
+
// Working state: the full catalog in display order (enabled entries in
|
|
344
|
+
// their configured positions, disabled ones trailing canonically) plus
|
|
345
|
+
// the enabled set. Persisted shape is the enabled subsequence only.
|
|
346
|
+
const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
|
|
347
|
+
const seen = new Set(enabled)
|
|
348
|
+
return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
|
|
349
|
+
})
|
|
350
|
+
const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
|
|
351
|
+
const [cursor, setCursor] = useState(0)
|
|
352
|
+
const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
|
|
353
|
+
setOrder(nextOrder)
|
|
354
|
+
setOn(nextOn)
|
|
355
|
+
change(nextOrder.filter(id => nextOn.has(id)))
|
|
356
|
+
}
|
|
357
|
+
const move = (offset: number): void => {
|
|
358
|
+
const target = cursor + offset
|
|
359
|
+
if (target < 0 || target >= order.length) return
|
|
360
|
+
const next = [...order]
|
|
361
|
+
const [item] = next.splice(cursor, 1)
|
|
362
|
+
next.splice(target, 0, item!)
|
|
363
|
+
commit(next, on)
|
|
364
|
+
setCursor(target)
|
|
365
|
+
}
|
|
366
|
+
useInput((input, key) => {
|
|
367
|
+
if (key.escape || input === 'q' || key.return) return close()
|
|
368
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
369
|
+
if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
|
|
370
|
+
if (key.leftArrow) return move(-1)
|
|
371
|
+
if (key.rightArrow) return move(1)
|
|
372
|
+
if (input === 'g') return setCursor(0)
|
|
373
|
+
if (input === 'G') return setCursor(order.length - 1)
|
|
374
|
+
if (input === 'd') {
|
|
375
|
+
commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
|
|
376
|
+
setCursor(0)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (input === ' ') {
|
|
380
|
+
const item = order[cursor]
|
|
381
|
+
if (item === undefined) return
|
|
382
|
+
const next = new Set(on)
|
|
383
|
+
if (next.has(item)) next.delete(item)
|
|
384
|
+
else next.add(item)
|
|
385
|
+
commit(order, next)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
const stdout = useStdout().stdout
|
|
389
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
390
|
+
if (viewport.compact) {
|
|
391
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
|
|
392
|
+
}
|
|
393
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
394
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
395
|
+
const offset = revealRow(0, cursor, order.length, bodyRows)
|
|
396
|
+
const visible = order.slice(offset, offset + bodyRows)
|
|
397
|
+
const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
|
|
398
|
+
return createElement(
|
|
399
|
+
Box,
|
|
400
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
|
|
401
|
+
createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
|
|
402
|
+
...visible.map((id, index) => {
|
|
403
|
+
const absolute = offset + index
|
|
404
|
+
const selected = absolute === cursor
|
|
405
|
+
const info = meta.get(id)
|
|
406
|
+
return createElement(
|
|
407
|
+
Text,
|
|
408
|
+
{
|
|
409
|
+
key: id,
|
|
410
|
+
color: selected ? color(TUI_RGB.brandBright) : undefined,
|
|
411
|
+
dimColor: !on.has(id) || undefined,
|
|
412
|
+
wrap: 'truncate-end',
|
|
413
|
+
},
|
|
414
|
+
truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
|
|
415
|
+
)
|
|
416
|
+
}),
|
|
417
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
|
|
418
|
+
)
|
|
419
|
+
}
|
|
@@ -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/animations.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal animation frame tables derived from the web design language:
|
|
3
3
|
* the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
|
|
4
|
-
* steps, 1s cycle) becomes the single-cell stepped pulse below
|
|
4
|
+
* steps, 1s cycle) becomes the single-cell stepped pulse below and the
|
|
5
|
+
* full-ring clockwise braille chase in {@link BUSY_CHASE_FRAMES}, and the
|
|
5
6
|
* streaming caret blink is the Claude-Code convention. Pure functions only —
|
|
6
7
|
* the Ink layer owns timers and colors.
|
|
7
8
|
*
|
|
@@ -16,6 +17,18 @@ export function pulseFrame(tick: number): string {
|
|
|
16
17
|
return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0]
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
|
|
22
|
+
* ring trail clockwise around the eight outer positions, one braille glyph
|
|
23
|
+
* per step — 8 frames × 125ms = the web's 1s cycle.
|
|
24
|
+
*/
|
|
25
|
+
export const BUSY_CHASE_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
|
|
26
|
+
|
|
27
|
+
/** Chase frame for a monotonic tick (the busy composer/Deep-diving marker). */
|
|
28
|
+
export function busyChaseFrame(tick: number): string {
|
|
29
|
+
return BUSY_CHASE_FRAMES[tick % BUSY_CHASE_FRAMES.length] ?? BUSY_CHASE_FRAMES[0]
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
/** Caret visibility: half the ticks on, half off (530ms blink). */
|
|
20
33
|
export function caretVisible(tick: number): boolean {
|
|
21
34
|
return tick % 2 === 0
|
package/src/render/export.ts
CHANGED
|
@@ -66,6 +66,10 @@ export function buildExportMarkdown(view: TranscriptView, sessionId: string): st
|
|
|
66
66
|
case 'files':
|
|
67
67
|
out.push(`> files changed: ${entry.paths.join(', ')}`, '')
|
|
68
68
|
break
|
|
69
|
+
case 'pending':
|
|
70
|
+
// Codex PendingSteer: queued prompts export like ordinary user rows.
|
|
71
|
+
out.push('## user', '', entry.text, '')
|
|
72
|
+
break
|
|
69
73
|
default:
|
|
70
74
|
assertNever(entry, 'transcript entry kind')
|
|
71
75
|
}
|
package/src/render/inspector.ts
CHANGED
|
@@ -6,14 +6,23 @@ 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
|
|
13
|
+
/** Safe outer width for a bordered dynamic panel; never writes column N. */
|
|
14
|
+
outerColumns: number
|
|
11
15
|
/** Tiny terminals use a borderless one-line close hint. */
|
|
12
16
|
compact: boolean
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
/**
|
|
16
|
-
const INSPECTOR_CHROME_ROWS =
|
|
19
|
+
/** Composer (3) + two-row status chrome, plus one optional fixed-height local notice row. */
|
|
20
|
+
const INSPECTOR_CHROME_ROWS = 6
|
|
21
|
+
|
|
22
|
+
/** One transcript-to-composer gutter, collapsed on short terminals. */
|
|
23
|
+
export function layoutGutterRows(rows: number): 0 | 1 {
|
|
24
|
+
return Math.max(1, Math.floor(rows)) >= 14 ? 1 : 0
|
|
25
|
+
}
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
28
|
* Keep the inspector plus its persistent status/composer chrome below
|
|
@@ -27,14 +36,23 @@ export function panelViewport(columns: number, rows: number): InspectorViewport
|
|
|
27
36
|
// scrollback into a tall dynamic panel. A one-row margin is insufficient:
|
|
28
37
|
// the transition can still take the full-terminal rewrite path at rows - 1.
|
|
29
38
|
const maxHeight = Math.max(0, Math.min(
|
|
30
|
-
safeRows - 2 - INSPECTOR_CHROME_ROWS,
|
|
39
|
+
safeRows - 2 - INSPECTOR_CHROME_ROWS - layoutGutterRows(safeRows),
|
|
31
40
|
Math.floor(safeRows / 2),
|
|
32
41
|
))
|
|
33
42
|
const compact = maxHeight < 5 || safeColumns < 8
|
|
43
|
+
const gapRows = !compact && maxHeight >= 7 ? 2 : 0
|
|
44
|
+
// A terminal may autowrap a glyph written into its final column. Ink still
|
|
45
|
+
// accounts for that border as one logical row, so the next dynamic update
|
|
46
|
+
// erases too few physical rows and leaves stacked frames behind. Codex
|
|
47
|
+
// renders overlays within an inset surface; reserve the final column here
|
|
48
|
+
// so every Ink panel follows the same contract.
|
|
49
|
+
const outerColumns = compact ? safeColumns : Math.max(1, safeColumns - 1)
|
|
34
50
|
return {
|
|
35
51
|
maxHeight,
|
|
36
|
-
bodyRows: compact ? 0 : maxHeight - 4,
|
|
37
|
-
|
|
52
|
+
bodyRows: compact ? 0 : maxHeight - 4 - gapRows,
|
|
53
|
+
gapRows,
|
|
54
|
+
contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, outerColumns - 4),
|
|
55
|
+
outerColumns,
|
|
38
56
|
compact,
|
|
39
57
|
}
|
|
40
58
|
}
|