dsh-oc-tui 0.1.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/LICENSE +504 -0
- package/README.md +247 -0
- package/bin/dsh-oc-tui.js +219 -0
- package/cordis.patch.yml +24 -0
- package/docs/max-thinking.gif +0 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +324 -0
- package/lib/index.js +1771 -0
- package/lib/interrupt.js +31 -0
- package/lib/markdown.js +297 -0
- package/lib/metrics.js +70 -0
- package/lib/startup.js +42 -0
- package/lib/term.js +506 -0
- package/lib/ui.js +1646 -0
- package/lib/util.js +281 -0
- package/lib/web-settings.js +450 -0
- package/package.json +61 -0
package/lib/interrupt.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class InterruptState {
|
|
2
|
+
constructor({ confirmMs = 1500 } = {}) {
|
|
3
|
+
this.confirmMs = confirmMs
|
|
4
|
+
this.armedUntil = 0
|
|
5
|
+
this.exiting = false
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interrupt({ running, hasInput = false, now = Date.now() }) {
|
|
9
|
+
if (this.exiting) return 'none'
|
|
10
|
+
if (hasInput) {
|
|
11
|
+
this.armedUntil = 0
|
|
12
|
+
return 'clear'
|
|
13
|
+
}
|
|
14
|
+
if (running) {
|
|
15
|
+
this.armedUntil = 0
|
|
16
|
+
return 'cancel'
|
|
17
|
+
}
|
|
18
|
+
if (now <= this.armedUntil) {
|
|
19
|
+
this.exiting = true
|
|
20
|
+
return 'exit'
|
|
21
|
+
}
|
|
22
|
+
this.armedUntil = now + this.confirmMs
|
|
23
|
+
return 'arm-exit'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
requestExit() {
|
|
27
|
+
if (this.exiting) return false
|
|
28
|
+
this.exiting = true
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/markdown.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Minimal markdown -> styled terminal lines. Each line is an array of
|
|
2
|
+
// { text, style } segments. Not a full spec implementation: covers the block
|
|
3
|
+
// shapes and inline spans most model output uses, and degrades gracefully.
|
|
4
|
+
import { makeStyle, mergeStyle } from './term.js'
|
|
5
|
+
import { runeWidth } from './util.js'
|
|
6
|
+
|
|
7
|
+
// Parse inline markdown into styled segments. `base` is merged into each.
|
|
8
|
+
// Handles **bold**, *italic*, `code`, [text](url), and ~~strike~~.
|
|
9
|
+
export function inlineSegments(text, theme, base = null) {
|
|
10
|
+
const segments = []
|
|
11
|
+
const buf = []
|
|
12
|
+
const flush = (style) => {
|
|
13
|
+
if (buf.length === 0) return
|
|
14
|
+
segments.push({ text: buf.join(''), style: base ? mergeStyle(base, style) : style })
|
|
15
|
+
buf.length = 0
|
|
16
|
+
}
|
|
17
|
+
const plain = () => flush(makeStyle({ fg: theme.text }))
|
|
18
|
+
let i = 0
|
|
19
|
+
const n = text.length
|
|
20
|
+
while (i < n) {
|
|
21
|
+
const rest = text.slice(i)
|
|
22
|
+
// code span
|
|
23
|
+
if (rest.startsWith('`')) {
|
|
24
|
+
plain()
|
|
25
|
+
let j = i + 1
|
|
26
|
+
let code = ''
|
|
27
|
+
let closed = false
|
|
28
|
+
while (j < n) {
|
|
29
|
+
if (text[j] === '`') { closed = true; break }
|
|
30
|
+
code += text[j]
|
|
31
|
+
j++
|
|
32
|
+
}
|
|
33
|
+
if (closed) {
|
|
34
|
+
flush(makeStyle({ fg: theme.markdownCode }))
|
|
35
|
+
segments.push({ text: code, style: makeStyle({ fg: theme.markdownCode, bg: theme.codeBg }) })
|
|
36
|
+
i = j + 1
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
buf.push('`')
|
|
40
|
+
i += 1
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
// bold
|
|
44
|
+
if (rest.startsWith('**')) {
|
|
45
|
+
plain()
|
|
46
|
+
const end = text.indexOf('**', i + 2)
|
|
47
|
+
if (end !== -1) {
|
|
48
|
+
flush(makeStyle({ fg: theme.text, bold: true }))
|
|
49
|
+
const inner = inlineSegments(text.slice(i + 2, end), theme, makeStyle({ bold: true }))
|
|
50
|
+
for (const seg of inner) segments.push(seg)
|
|
51
|
+
i = end + 2
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// italic
|
|
56
|
+
if (rest.startsWith('*')) {
|
|
57
|
+
plain()
|
|
58
|
+
const end = text.indexOf('*', i + 1)
|
|
59
|
+
if (end !== -1) {
|
|
60
|
+
flush(makeStyle({ fg: theme.text, italic: true }))
|
|
61
|
+
const inner = inlineSegments(text.slice(i + 1, end), theme, makeStyle({ italic: true }))
|
|
62
|
+
for (const seg of inner) segments.push(seg)
|
|
63
|
+
i = end + 1
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// link [text](url)
|
|
68
|
+
if (rest.startsWith('[')) {
|
|
69
|
+
const close = text.indexOf(']', i)
|
|
70
|
+
if (close !== -1 && text[close + 1] === '(') {
|
|
71
|
+
const urlEnd = text.indexOf(')', close + 2)
|
|
72
|
+
if (urlEnd !== -1) {
|
|
73
|
+
plain()
|
|
74
|
+
const label = text.slice(i + 1, close)
|
|
75
|
+
segments.push({ text: label, style: makeStyle({ fg: theme.markdownLinkText, underline: true }) })
|
|
76
|
+
i = urlEnd + 1
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// strike
|
|
82
|
+
if (rest.startsWith('~~')) {
|
|
83
|
+
const end = text.indexOf('~~', i + 2)
|
|
84
|
+
if (end !== -1) {
|
|
85
|
+
plain()
|
|
86
|
+
flush(makeStyle({ fg: theme.text, dim: true }))
|
|
87
|
+
const inner = inlineSegments(text.slice(i + 2, end), theme, makeStyle({ dim: true }))
|
|
88
|
+
for (const seg of inner) segments.push(seg)
|
|
89
|
+
i = end + 2
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A backslash is literal text (Windows paths keep their separators).
|
|
94
|
+
buf.push(text[i])
|
|
95
|
+
i += 1
|
|
96
|
+
}
|
|
97
|
+
flush(makeStyle({ fg: theme.text }))
|
|
98
|
+
return segments
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Wrap inline segments to `width` cells, returning lines of segments.
|
|
102
|
+
export function wrapSegments(segments, width) {
|
|
103
|
+
if (width <= 0) return [[]]
|
|
104
|
+
const lines = []
|
|
105
|
+
let current = []
|
|
106
|
+
let currentW = 0
|
|
107
|
+
let word = []
|
|
108
|
+
let wordW = 0
|
|
109
|
+
const pushWord = () => {
|
|
110
|
+
if (wordW === 0) return
|
|
111
|
+
if (currentW + wordW > width && current.length > 0) {
|
|
112
|
+
lines.push(current)
|
|
113
|
+
current = []
|
|
114
|
+
currentW = 0
|
|
115
|
+
}
|
|
116
|
+
if (wordW > width) {
|
|
117
|
+
let rest = word
|
|
118
|
+
let restW = wordW
|
|
119
|
+
while (restW > width) {
|
|
120
|
+
let acc = 0
|
|
121
|
+
let used = 0
|
|
122
|
+
outer: for (const seg of rest) {
|
|
123
|
+
for (const ch of seg.text) {
|
|
124
|
+
const w = runeWidth(ch)
|
|
125
|
+
if (acc + w > width) break outer
|
|
126
|
+
acc += w
|
|
127
|
+
used += ch.length
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const chunk = extractPrefixSegments(rest, used)
|
|
131
|
+
lines.push([...current, ...chunk])
|
|
132
|
+
current = []
|
|
133
|
+
currentW = 0
|
|
134
|
+
rest = consumePrefixSegments(rest, used)
|
|
135
|
+
restW = rest.reduce((s, seg) => s + segWidth(seg.text), 0)
|
|
136
|
+
}
|
|
137
|
+
for (const seg of rest) current.push(seg)
|
|
138
|
+
currentW = restW
|
|
139
|
+
} else {
|
|
140
|
+
for (const seg of word) current.push(seg)
|
|
141
|
+
currentW += wordW
|
|
142
|
+
}
|
|
143
|
+
word = []
|
|
144
|
+
wordW = 0
|
|
145
|
+
}
|
|
146
|
+
for (const seg of segments) {
|
|
147
|
+
const parts = seg.text.split(/(\s+)/)
|
|
148
|
+
for (const part of parts) {
|
|
149
|
+
if (part === '') continue
|
|
150
|
+
if (/^\s+$/.test(part)) {
|
|
151
|
+
pushWord()
|
|
152
|
+
if (currentW + runeWidth(part) <= width || current.length === 0) {
|
|
153
|
+
current.push({ text: part, style: seg.style })
|
|
154
|
+
currentW += runeWidth(part)
|
|
155
|
+
} else if (current.length > 0) {
|
|
156
|
+
lines.push(current)
|
|
157
|
+
current = []
|
|
158
|
+
currentW = 0
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
if (word.length > 0 && word[word.length - 1].style !== seg.style) pushWord()
|
|
162
|
+
word.push({ text: part, style: seg.style })
|
|
163
|
+
wordW += segWidth(part)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
pushWord()
|
|
168
|
+
if (current.length > 0 || lines.length === 0) lines.push(current)
|
|
169
|
+
return lines
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function segWidth(text) {
|
|
173
|
+
let w = 0
|
|
174
|
+
for (const ch of text) w += runeWidth(ch)
|
|
175
|
+
return w
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractPrefixSegments(segs, used) {
|
|
179
|
+
const out = []
|
|
180
|
+
let acc = 0
|
|
181
|
+
for (const seg of segs) {
|
|
182
|
+
if (acc >= used) break
|
|
183
|
+
const take = Math.min(used - acc, seg.text.length)
|
|
184
|
+
out.push({ text: seg.text.slice(0, take), style: seg.style })
|
|
185
|
+
acc += take
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
function consumePrefixSegments(segs, used) {
|
|
190
|
+
const out = []
|
|
191
|
+
let acc = 0
|
|
192
|
+
for (const seg of segs) {
|
|
193
|
+
if (acc >= used) {
|
|
194
|
+
out.push(seg)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
const take = Math.min(used - acc, seg.text.length)
|
|
198
|
+
acc += take
|
|
199
|
+
if (take < seg.text.length) out.push({ text: seg.text.slice(take), style: seg.style })
|
|
200
|
+
}
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const CODE_BG = '1e1e1e'
|
|
205
|
+
|
|
206
|
+
// Render markdown text to styled lines for the given width.
|
|
207
|
+
// Returns an array of lines; each line is an array of { text, style }.
|
|
208
|
+
export function renderMarkdown(text, theme, width) {
|
|
209
|
+
const lines = []
|
|
210
|
+
const raw = String(text).replace(/\r\n/g, '\n')
|
|
211
|
+
const blockLines = raw.split('\n')
|
|
212
|
+
let i = 0
|
|
213
|
+
let inCode = false
|
|
214
|
+
while (i < blockLines.length) {
|
|
215
|
+
const line = blockLines[i]
|
|
216
|
+
if (inCode) {
|
|
217
|
+
if (/^```/.test(line.trim())) {
|
|
218
|
+
inCode = false
|
|
219
|
+
lines.push([])
|
|
220
|
+
i++
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
const segs = [{ text: line, style: makeStyle({ fg: theme.markdownCodeBlock, bg: CODE_BG }) }]
|
|
224
|
+
pushWrapped(lines, segs, width)
|
|
225
|
+
i++
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const trimmed = line.trim()
|
|
229
|
+
const fence = /^```(\S*)/.exec(trimmed)
|
|
230
|
+
if (fence) {
|
|
231
|
+
inCode = true
|
|
232
|
+
lines.push([])
|
|
233
|
+
i++
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(trimmed)
|
|
237
|
+
if (h) {
|
|
238
|
+
lines.push([{ text: h[2], style: makeStyle({ fg: theme.markdownHeading, bold: true }) }])
|
|
239
|
+
i++
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (/^(---|\*\*\*|___)\s*$/.test(trimmed)) {
|
|
243
|
+
lines.push([{ text: '─'.repeat(Math.max(4, width)), style: makeStyle({ fg: theme.markdownHorizontalRule, dim: true }) }])
|
|
244
|
+
i++
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
const q = /^>\s?(.*)$/.exec(line)
|
|
248
|
+
if (q) {
|
|
249
|
+
const segs = [{ text: '▍ ', style: makeStyle({ fg: theme.markdownBlockQuote }) }]
|
|
250
|
+
segs.push(...inlineSegments(q[1], theme, makeStyle({ fg: theme.markdownBlockQuote })))
|
|
251
|
+
pushWrapped(lines, segs, width)
|
|
252
|
+
i++
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
const li = /^([-*+]|\d+\.)\s+(.*)$/.exec(trimmed)
|
|
256
|
+
if (li) {
|
|
257
|
+
const marker = /^\d/.test(li[1]) ? ' ' + li[1] + ' ' : '- '
|
|
258
|
+
const prefix = [{ text: marker, style: makeStyle({ fg: theme.markdownListItem, bold: true }) }]
|
|
259
|
+
prefix.push(...inlineSegments(li[2], theme))
|
|
260
|
+
pushWrapped(lines, prefix, width)
|
|
261
|
+
i++
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
if (trimmed === '') {
|
|
265
|
+
lines.push([])
|
|
266
|
+
i++
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
let para = line
|
|
270
|
+
while (i + 1 < blockLines.length && !startsNewBlock(blockLines[i + 1])) {
|
|
271
|
+
i++
|
|
272
|
+
para += ' ' + blockLines[i]
|
|
273
|
+
}
|
|
274
|
+
pushWrapped(lines, inlineSegments(para, theme), width)
|
|
275
|
+
i++
|
|
276
|
+
}
|
|
277
|
+
return lines
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Whether a raw line begins a block that must not merge into the paragraph
|
|
281
|
+
// above it: blank, list item, blockquote, fence, heading, or horizontal rule.
|
|
282
|
+
function startsNewBlock(raw) {
|
|
283
|
+
const trimmed = raw.trim()
|
|
284
|
+
if (trimmed === '') return true
|
|
285
|
+
if (/^```/.test(trimmed)) return true
|
|
286
|
+
if (/^#{1,4}\s/.test(trimmed)) return true
|
|
287
|
+
if (/^([-*+]|\d+\.)\s/.test(trimmed)) return true
|
|
288
|
+
if (/^>\s?/.test(trimmed)) return true
|
|
289
|
+
if (/^(---|\*\*\*|___)\s*$/.test(trimmed)) return true
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function pushWrapped(out, segments, width) {
|
|
294
|
+
for (const line of wrapSegments(segments, width)) {
|
|
295
|
+
out.push(line.length === 0 ? [] : line)
|
|
296
|
+
}
|
|
297
|
+
}
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export class SessionMetrics {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.reset()
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
reset() {
|
|
7
|
+
this.inputTokens = 0
|
|
8
|
+
this.outputTokens = 0
|
|
9
|
+
this.cacheReadTokens = 0
|
|
10
|
+
this.cacheWriteTokens = 0
|
|
11
|
+
this.ttftTotalMs = 0
|
|
12
|
+
this.ttftSamples = 0
|
|
13
|
+
this.decodeMs = 0
|
|
14
|
+
this.decodeTokens = 0
|
|
15
|
+
this.steps = new Map()
|
|
16
|
+
this.sampledTurns = new Set()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
consume(event) {
|
|
20
|
+
if (event.type === 'step/start') {
|
|
21
|
+
this.steps.set(stepKey(event.data), { start: event.time, first: null })
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
if (event.type === 'assistant/chunk') {
|
|
25
|
+
const chunk = event.data.chunk
|
|
26
|
+
if (chunk.type !== 'text-delta' && chunk.type !== 'reasoning-delta') return
|
|
27
|
+
const step = this.steps.get(stepKey(event.data))
|
|
28
|
+
if (step && step.first === null) step.first = event.time
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
if (event.type !== 'assistant/message') return
|
|
32
|
+
|
|
33
|
+
const usage = event.data.usage
|
|
34
|
+
if (usage) {
|
|
35
|
+
this.inputTokens += usage.inputTokens ?? 0
|
|
36
|
+
this.outputTokens += usage.outputTokens ?? 0
|
|
37
|
+
this.cacheReadTokens += usage.cacheReadTokens ?? 0
|
|
38
|
+
this.cacheWriteTokens += usage.cacheWriteTokens ?? 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const step = this.steps.get(stepKey(event.data))
|
|
42
|
+
if (!step || step.first === null) return
|
|
43
|
+
if (!this.sampledTurns.has(event.data.turn)) {
|
|
44
|
+
this.sampledTurns.add(event.data.turn)
|
|
45
|
+
this.ttftTotalMs += Math.max(0, step.first - step.start)
|
|
46
|
+
this.ttftSamples++
|
|
47
|
+
}
|
|
48
|
+
if (usage && event.time > step.first) {
|
|
49
|
+
this.decodeMs += event.time - step.first
|
|
50
|
+
this.decodeTokens += usage.outputTokens ?? 0
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
snapshot() {
|
|
55
|
+
const billedInput = this.inputTokens + this.cacheReadTokens + this.cacheWriteTokens
|
|
56
|
+
return {
|
|
57
|
+
inputTokens: this.inputTokens,
|
|
58
|
+
outputTokens: this.outputTokens,
|
|
59
|
+
cacheReadTokens: this.cacheReadTokens,
|
|
60
|
+
cacheWriteTokens: this.cacheWriteTokens,
|
|
61
|
+
cacheHitRate: billedInput > 0 ? Math.round((this.cacheReadTokens / billedInput) * 100) : undefined,
|
|
62
|
+
ttftAverageMs: this.ttftSamples > 0 ? this.ttftTotalMs / this.ttftSamples : undefined,
|
|
63
|
+
tokensPerSecond: this.decodeMs > 0 ? this.decodeTokens / (this.decodeMs / 1000) : undefined,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stepKey(data) {
|
|
69
|
+
return data.turn + ':' + data.step
|
|
70
|
+
}
|
package/lib/startup.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The TUI app's command-line provider: parses the dsh --profile tui flag
|
|
2
|
+
// family (--resume, --model, --provider, --sidebar) and its --help text, then
|
|
3
|
+
// provides the immutable values as the tuiStartup service. Ordinary rows
|
|
4
|
+
// inject that service and read it as a lazily-resolved value.
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
|
|
7
|
+
|
|
8
|
+
export const name = 'dsh-oc-tui/startup'
|
|
9
|
+
export const inject = ['cmdlineArgs']
|
|
10
|
+
export const TUI_STARTUP_SERVICE = 'tuiStartup'
|
|
11
|
+
|
|
12
|
+
function tuiCommand() {
|
|
13
|
+
return new Command()
|
|
14
|
+
.name('dsh --profile tui')
|
|
15
|
+
.description('Boot the DeepSeek Harness terminal UI.')
|
|
16
|
+
.helpOption('-h, --help', 'show this help')
|
|
17
|
+
.option('--resume <sessionId>', 'resume an existing persisted session by id')
|
|
18
|
+
.option('--model <modelId>', 'default model id for new sessions')
|
|
19
|
+
.option('--provider <provider>', 'default provider route for new sessions')
|
|
20
|
+
.option('--sidebar', 'show the session sidebar (default)')
|
|
21
|
+
.option('--no-sidebar', 'start without the sidebar')
|
|
22
|
+
.addHelpText('after', `
|
|
23
|
+
Examples:
|
|
24
|
+
dsh --profile tui start a fresh session
|
|
25
|
+
dsh --profile tui --resume abc123 resume a persisted session
|
|
26
|
+
dsh --profile tui --model deepseek-v4-flash
|
|
27
|
+
`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function apply(ctx) {
|
|
31
|
+
const program = tuiCommand()
|
|
32
|
+
program.action(() => {
|
|
33
|
+
const options = program.opts()
|
|
34
|
+
ctx.provide(TUI_STARTUP_SERVICE, {
|
|
35
|
+
resume: options.resume ?? null,
|
|
36
|
+
model: options.model ?? null,
|
|
37
|
+
provider: options.provider ?? null,
|
|
38
|
+
sidebar: options.sidebar !== false,
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
parseCmdline(ctx, program)
|
|
42
|
+
}
|