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.
@@ -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
+ }
@@ -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
+ }