dsh-oc-tui 0.2.0 → 0.3.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 +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- package/skills/mcp-import/SKILL.md +216 -216
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir, platform } from 'node:os'
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile)
|
|
8
|
+
let picturesDirectory
|
|
9
|
+
|
|
10
|
+
export function defaultPicturesDirectory() {
|
|
11
|
+
if (!picturesDirectory) picturesDirectory = findPicturesDirectory()
|
|
12
|
+
return picturesDirectory
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function findPicturesDirectory() {
|
|
16
|
+
const home = homedir()
|
|
17
|
+
if (platform() === 'win32') {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await execFileAsync('powershell.exe', [
|
|
20
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
21
|
+
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); [Environment]::GetFolderPath("MyPictures")',
|
|
22
|
+
], { timeout: 4000, windowsHide: true })
|
|
23
|
+
const directory = stdout.trim()
|
|
24
|
+
if (directory && isAbsolute(directory)) return directory
|
|
25
|
+
} catch { /* Windows can still use the conventional folder. */ }
|
|
26
|
+
} else if (platform() !== 'darwin') {
|
|
27
|
+
try {
|
|
28
|
+
const config = await readFile(join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'user-dirs.dirs'), 'utf8')
|
|
29
|
+
const match = /^XDG_PICTURES_DIR="([^"\r\n]*)"/m.exec(config)
|
|
30
|
+
const directory = match?.[1]?.replace(/^\$HOME(?=\/|$)/, home)
|
|
31
|
+
if (directory && isAbsolute(directory)) return directory
|
|
32
|
+
} catch { /* XDG user directories are optional. */ }
|
|
33
|
+
}
|
|
34
|
+
return join(home, 'Pictures')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeXml(value) {
|
|
38
|
+
return String(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '\ufffd')
|
|
39
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function color(value, fallback) {
|
|
43
|
+
if (typeof value !== 'string') return fallback
|
|
44
|
+
const hex = value.replace(/^#/, '')
|
|
45
|
+
if (/^[0-9a-f]{6}$/i.test(hex)) return '#' + hex
|
|
46
|
+
if (/^[0-9a-f]{3}$/i.test(hex)) return '#' + [...hex].map((ch) => ch + ch).join('')
|
|
47
|
+
return fallback
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The export is reconstructed from the same cells the terminal receives, so it
|
|
51
|
+
// works over SSH and does not require access to the user's desktop or clipboard.
|
|
52
|
+
export async function renderScreenshot(screen, { title = '', images = new Map() } = {}) {
|
|
53
|
+
const sharp = (await import('sharp')).default
|
|
54
|
+
const cellW = 10
|
|
55
|
+
const cellH = 20
|
|
56
|
+
const margin = 24
|
|
57
|
+
const titleH = 40
|
|
58
|
+
const width = screen.cols * cellW + margin * 2
|
|
59
|
+
const height = screen.rows * cellH + margin * 2 + titleH
|
|
60
|
+
if (width * height > 50_000_000) throw new Error('screen is too large to export')
|
|
61
|
+
const x0 = margin
|
|
62
|
+
const y0 = margin + titleH
|
|
63
|
+
const windowW = screen.cols * cellW
|
|
64
|
+
const windowH = titleH + screen.rows * cellH
|
|
65
|
+
const pieces = [
|
|
66
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`,
|
|
67
|
+
`<rect x="${x0}" y="${margin}" width="${windowW}" height="${windowH}" rx="7" fill="#151620" stroke="#373b53"/>`,
|
|
68
|
+
`<path d="M${x0} ${margin + titleH}H${x0 + windowW}" stroke="#303347"/>`,
|
|
69
|
+
`<circle cx="${x0 + 18}" cy="${margin + 20}" r="5" fill="#ff5f57"/>`,
|
|
70
|
+
`<circle cx="${x0 + 35}" cy="${margin + 20}" r="5" fill="#febc2e"/>`,
|
|
71
|
+
`<circle cx="${x0 + 52}" cy="${margin + 20}" r="5" fill="#28c840"/>`,
|
|
72
|
+
`<clipPath id="title"><rect x="${x0 + 69}" y="${margin}" width="${Math.max(0, windowW - 80)}" height="${titleH}"/></clipPath>`,
|
|
73
|
+
`<text x="${x0 + 70}" y="${margin + 25}" clip-path="url(#title)" fill="#a8b4d2" font-family="Cascadia Mono,Consolas,Microsoft YaHei UI,monospace" font-size="13">${escapeXml(title)}</text>`,
|
|
74
|
+
]
|
|
75
|
+
for (let y = 0; y < screen.rows; y++) {
|
|
76
|
+
const row = screen.cells[y]
|
|
77
|
+
for (let x = 0; x < screen.cols;) {
|
|
78
|
+
const bg = color(row[x].style?.bg, '#101114')
|
|
79
|
+
let end = x + 1
|
|
80
|
+
while (end < screen.cols && color(row[end].style?.bg, '#101114') === bg) end++
|
|
81
|
+
pieces.push(`<rect x="${x0 + x * cellW}" y="${y0 + y * cellH}" width="${(end - x) * cellW}" height="${cellH}" fill="${bg}"/>`)
|
|
82
|
+
x = end
|
|
83
|
+
}
|
|
84
|
+
for (let x = 0; x < screen.cols; x++) {
|
|
85
|
+
const cell = row[x]
|
|
86
|
+
const ch = cell.ch
|
|
87
|
+
if (!ch || ch === ' ') continue
|
|
88
|
+
const style = cell.style ?? {}
|
|
89
|
+
const fg = color(style.fg, '#e4e5eb')
|
|
90
|
+
const px = x0 + x * cellW
|
|
91
|
+
const py = y0 + y * cellH
|
|
92
|
+
if (ch === '▀' || ch === '▄' || ch === '█') {
|
|
93
|
+
const top = ch === '▄' ? cellH / 2 : 0
|
|
94
|
+
const h = ch === '█' ? cellH : cellH / 2
|
|
95
|
+
pieces.push(`<rect x="${px}" y="${py + top}" width="${cellW}" height="${h}" fill="${fg}"/>`)
|
|
96
|
+
} else {
|
|
97
|
+
const weight = style.bold ? 'bold' : 'normal'
|
|
98
|
+
const italic = style.italic ? 'italic' : 'normal'
|
|
99
|
+
const opacity = style.dim ? '0.65' : '1'
|
|
100
|
+
const underline = style.underline ? ' text-decoration="underline"' : ''
|
|
101
|
+
pieces.push(`<text x="${px}" y="${py + 16}" fill="${fg}" opacity="${opacity}" font-family="Cascadia Mono,Consolas,Microsoft YaHei UI,Noto Sans Mono CJK SC,monospace" font-size="16" font-weight="${weight}" font-style="${italic}"${underline}>${escapeXml(ch)}</text>`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
pieces.push('</svg>')
|
|
106
|
+
const imageLayers = []
|
|
107
|
+
for (const image of screen.images ?? []) {
|
|
108
|
+
const payload = images.get(image.key)
|
|
109
|
+
const bytes = payload?.png ?? (payload?.b64 ? Buffer.from(payload.b64, 'base64') : null)
|
|
110
|
+
if (!bytes) continue
|
|
111
|
+
const imageW = Math.max(1, image.cellsW * cellW)
|
|
112
|
+
const imageH = Math.max(1, image.cellsH * cellH)
|
|
113
|
+
const visibleW = Math.min(imageW, (screen.cols - image.x) * cellW)
|
|
114
|
+
const visibleH = Math.min(imageH, (screen.rows - image.y) * cellH)
|
|
115
|
+
if (image.x < 0 || image.y < 0 || visibleW <= 0 || visibleH <= 0) continue
|
|
116
|
+
const input = await sharp(bytes).resize(imageW, imageH, { fit: 'fill' })
|
|
117
|
+
.extract({ left: 0, top: 0, width: visibleW, height: visibleH }).png().toBuffer()
|
|
118
|
+
imageLayers.push({ input, left: x0 + image.x * cellW, top: y0 + image.y * cellH })
|
|
119
|
+
}
|
|
120
|
+
return sharp(Buffer.from(pieces.join(''))).composite(imageLayers).png().toBuffer()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function saveScreenshot(screen, { directory, title, images, now = new Date() } = {}) {
|
|
124
|
+
const choice = typeof directory === 'string' ? directory.trim() : ''
|
|
125
|
+
const expanded = choice === '~' ? homedir() : choice.replace(/^~[\\/]/, homedir() + '/')
|
|
126
|
+
const target = expanded ? resolve(expanded) : await defaultPicturesDirectory()
|
|
127
|
+
const png = await renderScreenshot(screen, { title, images })
|
|
128
|
+
await mkdir(target, { recursive: true })
|
|
129
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-')
|
|
130
|
+
for (let suffix = 0; suffix < 1000; suffix++) {
|
|
131
|
+
const path = join(target, `dsh-screenshot-${stamp}${suffix ? '-' + suffix : ''}.png`)
|
|
132
|
+
try {
|
|
133
|
+
await writeFile(path, png, { flag: 'wx' })
|
|
134
|
+
return path
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code !== 'EEXIST') throw error
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
throw new Error('could not choose a unique screenshot filename')
|
|
140
|
+
}
|
package/lib/splash-opencode.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// OpenCode-style splash screen: the DeepSeek brand mark with input hints
|
|
2
2
|
import { t as tr } from './i18n.js'
|
|
3
|
-
import { displayWidth } from './util.js'
|
|
3
|
+
import { displayWidth, shortenPath, truncateWidth } from './util.js'
|
|
4
4
|
|
|
5
5
|
// The DeepSeek mark as it has always been drawn on the title screen: the
|
|
6
6
|
// name in a blue→white gradient, set as one line rather than ASCII art.
|
|
7
7
|
const BRAND = 'DeepSeek Harness'
|
|
8
|
+
const BRAND_BLUE = '#4d6bfe'
|
|
8
9
|
const BRAND_TAIL = '#ffffff'
|
|
9
10
|
|
|
10
11
|
// Color mixing helper (linear interpolation in RGB space)
|
|
@@ -27,26 +28,21 @@ function mixColor(a, b, t) {
|
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Paint the OpenCode-style splash screen:
|
|
30
|
-
* - DeepSeek brand mark
|
|
31
|
-
* -
|
|
32
|
-
* - Input prompt below
|
|
33
|
-
* - Keyboard shortcuts at bottom
|
|
31
|
+
* - DeepSeek brand mark above the centered composer
|
|
32
|
+
* - Keyboard shortcuts and workspace/git context below it
|
|
34
33
|
*
|
|
35
34
|
* @param {Screen} screen - The screen buffer to paint to
|
|
36
35
|
* @param {number} cols - Terminal columns
|
|
37
36
|
* @param {number} rows - Terminal rows
|
|
38
37
|
* @param {object} theme - Color theme
|
|
39
38
|
* @param {object} ctx - Context: { workingDirectory, gitBranch, model, provider }
|
|
40
|
-
* @param {number} floor -
|
|
41
|
-
* @param {object} area -
|
|
42
|
-
* Defaults to the full screen; the status rail passes the main area's band so
|
|
43
|
-
* the brand never centers under the rail.
|
|
39
|
+
* @param {number} floor - Shortcut row for a standalone splash
|
|
40
|
+
* @param {object} area - Composer band: { x0, width, composerTop, composerBottom }.
|
|
44
41
|
*/
|
|
45
42
|
export function paintSplashOpenCode(screen, cols, rows, theme, ctx = {}, floor = rows - 1, area = {}) {
|
|
46
43
|
const chars = Array.from(BRAND)
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
const brandRow = Math.max(0, Math.floor((rows - 1) / 2) - 3)
|
|
45
|
+
const brandRow = Math.max(1, (area.composerTop ?? Math.floor((rows - 1) / 2)) - 3)
|
|
50
46
|
|
|
51
47
|
// The horizontal band: bounded by the caller, centered content within it.
|
|
52
48
|
const x0 = Math.max(0, Math.min(area.x0 ?? 0, cols - 1))
|
|
@@ -60,49 +56,56 @@ export function paintSplashOpenCode(screen, cols, rows, theme, ctx = {}, floor =
|
|
|
60
56
|
const brandX = centerX(BRAND)
|
|
61
57
|
for (let i = 0; i < chars.length; i++) {
|
|
62
58
|
const t = chars.length > 1 ? i / (chars.length - 1) : 0
|
|
63
|
-
screen.text(brandX + i, brandRow, chars[i], { fg: mixColor(
|
|
59
|
+
screen.text(brandX + i, brandRow, chars[i], { fg: mixColor(BRAND_BLUE, BRAND_TAIL, t), bold: true, bg: theme.background })
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
const shortcutsY = Math.min(rows - 2, floor)
|
|
62
|
+
// Keep shortcuts close to the input while reserving the last status row.
|
|
63
|
+
const shortcutsY = Math.min(rows - 2, area.composerBottom != null ? area.composerBottom + 2 : floor)
|
|
69
64
|
|
|
70
65
|
// Workspace/git/model info below the mark
|
|
71
|
-
let infoY = brandRow + 2
|
|
66
|
+
let infoY = area.composerBottom != null ? shortcutsY + 2 : brandRow + 2
|
|
72
67
|
const { workingDirectory = '', gitBranch = '', model = '', provider = '' } = ctx
|
|
73
68
|
|
|
74
|
-
if (workingDirectory && infoY <
|
|
75
|
-
const
|
|
76
|
-
|
|
69
|
+
if (workingDirectory && infoY < rows - 2) {
|
|
70
|
+
const branch = gitBranch ? ' git: ' + truncateWidth(gitBranch, Math.max(4, Math.floor(bandW / 3))) : ''
|
|
71
|
+
const info = shortenPath(workingDirectory, Math.max(4, bandW - displayWidth(branch))) + branch
|
|
72
|
+
screen.text(centerX(info), infoY, truncateWidth(info, bandW), { fg: theme.textMuted, bg: theme.background })
|
|
77
73
|
infoY++
|
|
78
74
|
}
|
|
79
75
|
|
|
80
|
-
if (model && infoY < shortcutsY - 1) {
|
|
76
|
+
if (area.composerTop == null && model && infoY < shortcutsY - 1) {
|
|
81
77
|
const modelInfo = provider ? `${provider}/${model}` : model
|
|
82
|
-
screen.text(centerX(modelInfo), infoY, modelInfo, { fg: theme.secondary, bg: theme.background })
|
|
78
|
+
screen.text(centerX(modelInfo), infoY, truncateWidth(modelInfo, bandW), { fg: theme.secondary, bg: theme.background })
|
|
83
79
|
infoY++
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
// Input hint below info
|
|
87
83
|
const hintY = infoY + 1
|
|
88
|
-
if (hintY < shortcutsY) {
|
|
84
|
+
if (area.composerTop == null && hintY < shortcutsY) {
|
|
89
85
|
const hint = tr('splash.hint')
|
|
90
|
-
screen.text(centerX(hint), hintY, hint, { fg: theme.textMuted, bg: theme.background })
|
|
86
|
+
screen.text(centerX(hint), hintY, truncateWidth(hint, bandW), { fg: theme.textMuted, bg: theme.background })
|
|
91
87
|
}
|
|
92
88
|
|
|
93
89
|
// Keyboard shortcuts at bottom: only affordances that actually exist on the
|
|
94
90
|
// title screen. A narrow terminal drops whole groups rather than cutting any
|
|
95
|
-
// group in half; settings
|
|
96
|
-
// switcher the least, so the drop order is thinking then branches. Each
|
|
91
|
+
// group in half; settings and thinking take priority over branches. Each
|
|
97
92
|
// group paints its key in the accent color and bold so the key reads apart
|
|
98
93
|
// from the muted description that follows it.
|
|
99
|
-
if (shortcutsY >
|
|
94
|
+
if (shortcutsY > brandRow + 2) {
|
|
100
95
|
const sep = ' '
|
|
101
96
|
const pairs = [
|
|
102
97
|
['Ctrl+P', tr('splash.shortcutSettings')],
|
|
103
98
|
['Tab', tr('splash.shortcutThinking')],
|
|
104
99
|
['Ctrl+G', tr('splash.shortcutBranches')],
|
|
100
|
+
['Ctrl+S', tr('screenshot.shortcut')],
|
|
105
101
|
]
|
|
102
|
+
const essentialWidth = pairs.slice(0, 2).reduce((n, [key, desc]) => n + displayWidth(key) + 2 + displayWidth(desc), displayWidth(sep))
|
|
103
|
+
if (essentialWidth > bandW - 2) {
|
|
104
|
+
const descBudget = Math.max(0, bandW - 2 - displayWidth('Ctrl+P Tab') - displayWidth(sep) - 2)
|
|
105
|
+
const firstRoom = Math.min(displayWidth(pairs[0][1]), Math.floor(descBudget / 2))
|
|
106
|
+
pairs[0][1] = truncateWidth(pairs[0][1], firstRoom)
|
|
107
|
+
pairs[1][1] = truncateWidth(pairs[1][1], descBudget - firstRoom)
|
|
108
|
+
}
|
|
106
109
|
const kept = []
|
|
107
110
|
let width = 0
|
|
108
111
|
for (const [key, desc] of pairs) {
|
package/lib/startup.js
CHANGED
|
@@ -1,48 +1,66 @@
|
|
|
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
|
-
import { resolveLocale, setLocale, t } from './i18n.js'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export const
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.
|
|
22
|
-
.
|
|
23
|
-
.
|
|
24
|
-
.option('--
|
|
25
|
-
.option('--
|
|
26
|
-
.option('--
|
|
27
|
-
.option('--
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
dsh --profile tui
|
|
32
|
-
dsh --profile tui --
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
import { resolveLocale, setLocale, t } from './i18n.js'
|
|
8
|
+
import { createMcpMonitor, createMcpNotifier, filterMcpStderrWrites, isolateMcpOutput } from './mcp-monitor.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'dsh-oc-tui/startup'
|
|
11
|
+
export const inject = ['cmdlineArgs']
|
|
12
|
+
export const TUI_STARTUP_SERVICE = 'tuiStartup'
|
|
13
|
+
|
|
14
|
+
// The command line is parsed before the plugin applies, so the settings file is
|
|
15
|
+
// not readable here: the help text follows the environment (DSH_TUI_LOCALE, or
|
|
16
|
+
// the OS language) and the boot itself then re-resolves the stored preference.
|
|
17
|
+
try { setLocale(resolveLocale('system')) } catch { /* the help falls back to English */ }
|
|
18
|
+
|
|
19
|
+
function tuiCommand() {
|
|
20
|
+
return new Command()
|
|
21
|
+
.name('dsh --profile tui')
|
|
22
|
+
.description(t('startup.description'))
|
|
23
|
+
.helpOption('-h, --help', t('startup.helpOption'))
|
|
24
|
+
.option('--resume <sessionId>', t('startup.resume'))
|
|
25
|
+
.option('--model <modelId>', t('startup.model'))
|
|
26
|
+
.option('--provider <provider>', t('startup.provider'))
|
|
27
|
+
.option('--sidebar', t('startup.sidebar'))
|
|
28
|
+
.option('--no-sidebar', t('startup.noSidebar'))
|
|
29
|
+
.addHelpText('after', `
|
|
30
|
+
${t('startup.examples')}
|
|
31
|
+
dsh --profile tui ${t('startup.exampleFresh')}
|
|
32
|
+
dsh --profile tui --resume abc123 ${t('startup.exampleResume')}
|
|
33
|
+
dsh --profile tui --model deepseek-v4-flash
|
|
34
|
+
`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function apply(ctx) {
|
|
38
|
+
// An MCP server that cannot be reached must not write to the terminal: the
|
|
39
|
+
// host's client reports the failure there, and a stdio server inherits the
|
|
40
|
+
// terminal's stderr. The isolation goes in before the app mounts, so the
|
|
41
|
+
// output is gone before anything can paint over the frame. The state is
|
|
42
|
+
// watched from here too — the host's MCP rows are mounted next.
|
|
43
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
44
|
+
const restoreOutput = interactive ? isolateMcpOutput() : null
|
|
45
|
+
const restoreStderr = interactive ? filterMcpStderrWrites() : null
|
|
46
|
+
const mcpMonitor = interactive ? createMcpMonitor(ctx) : null
|
|
47
|
+
const mcpNotifier = createMcpNotifier()
|
|
48
|
+
ctx.effect(() => () => {
|
|
49
|
+
mcpMonitor?.detach()
|
|
50
|
+
restoreStderr?.()
|
|
51
|
+
restoreOutput?.()
|
|
52
|
+
})
|
|
53
|
+
const program = tuiCommand()
|
|
54
|
+
program.action(() => {
|
|
55
|
+
const options = program.opts()
|
|
56
|
+
ctx.provide(TUI_STARTUP_SERVICE, {
|
|
57
|
+
resume: options.resume ?? null,
|
|
58
|
+
model: options.model ?? null,
|
|
59
|
+
provider: options.provider ?? null,
|
|
60
|
+
sidebar: options.sidebar !== false,
|
|
61
|
+
mcpMonitor,
|
|
62
|
+
mcpNotifier,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
parseCmdline(ctx, program)
|
|
66
|
+
}
|
package/lib/term.js
CHANGED
|
@@ -33,15 +33,27 @@ export function makeStyle(partial = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Stand-in for an absent style layer in mergeStyle(). Frozen because styles
|
|
37
|
+
// are treated as immutable everywhere else; nothing may write through it.
|
|
38
|
+
const EMPTY_STYLE = Object.freeze(makeStyle({}))
|
|
39
|
+
|
|
40
|
+
// A segment's `style` is null until something paints it — markdown emits the
|
|
41
|
+
// indent pads of a wrapped list item and the bare prefix pads that way — so
|
|
42
|
+
// both layers are legitimately absent. Reading a missing layer as "sets no
|
|
43
|
+
// properties" keeps `mergeStyle(seg.style, tint)` total: tinting a
|
|
44
|
+
// null-styled segment is the first thing a collapsed thinking/note body does
|
|
45
|
+
// when the reader expands it.
|
|
36
46
|
export function mergeStyle(base, over) {
|
|
47
|
+
const b = base ?? EMPTY_STYLE
|
|
48
|
+
const o = over ?? EMPTY_STYLE
|
|
37
49
|
return {
|
|
38
|
-
fg:
|
|
39
|
-
bg:
|
|
40
|
-
bold:
|
|
41
|
-
dim:
|
|
42
|
-
italic:
|
|
43
|
-
underline:
|
|
44
|
-
link:
|
|
50
|
+
fg: o.fg ?? b.fg ?? null,
|
|
51
|
+
bg: o.bg ?? b.bg ?? null,
|
|
52
|
+
bold: o.bold ?? b.bold ?? false,
|
|
53
|
+
dim: o.dim ?? b.dim ?? false,
|
|
54
|
+
italic: o.italic ?? b.italic ?? false,
|
|
55
|
+
underline: o.underline ?? b.underline ?? false,
|
|
56
|
+
link: o.link ?? b.link,
|
|
45
57
|
}
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -226,6 +238,19 @@ function csiModifierFlags(param) {
|
|
|
226
238
|
return { shift: (bits & 1) !== 0, alt: (bits & 2) !== 0, ctrl: (bits & 4) !== 0 }
|
|
227
239
|
}
|
|
228
240
|
|
|
241
|
+
// Enter with modifiers, carrying only the flags that are actually set: an
|
|
242
|
+
// absent flag means "not pressed", which is what the composer's newline check
|
|
243
|
+
// tests. Both CSI-u and modifyOtherKeys encode the modifier as 1 + bitmask
|
|
244
|
+
// (shift 1, alt 2, ctrl 4, meta 8).
|
|
245
|
+
function enterKey(modifier) {
|
|
246
|
+
const bits = Math.max(0, (Number(modifier) || 1) - 1)
|
|
247
|
+
const key = { name: 'enter' }
|
|
248
|
+
if (bits & 1) key.shift = true
|
|
249
|
+
if (bits & 2) key.alt = true
|
|
250
|
+
if (bits & 4) key.ctrl = true
|
|
251
|
+
return key
|
|
252
|
+
}
|
|
253
|
+
|
|
229
254
|
// Decode one key event from a raw-mode byte buffer. Returns { key } or null
|
|
230
255
|
// when more bytes are needed.
|
|
231
256
|
export function decodeKey(input) {
|
|
@@ -365,13 +390,27 @@ export function decodeKey(input) {
|
|
|
365
390
|
if (final === 'F') return { key: { name: 'end' }, consumed }
|
|
366
391
|
if (final === 'Z') return { key: { name: 'shift-tab' }, consumed }
|
|
367
392
|
if (final === 'u') {
|
|
393
|
+
// CSI-u spells the key out by codepoint, so Enter stops being a bare
|
|
394
|
+
// CR and Ctrl+Enter becomes distinguishable from Enter. This is the
|
|
395
|
+
// shape the newline binding depends on: without it a terminal that
|
|
396
|
+
// cannot disambiguate sends the same byte for both.
|
|
368
397
|
const [code, modifier = 1] = param.split(';').map(Number)
|
|
369
|
-
if (code === 13
|
|
370
|
-
if (code === 13 && modifier === 5) return { key: { name: 'enter', ctrl: true }, consumed }
|
|
398
|
+
if (code === 13) return { key: enterKey(modifier), consumed }
|
|
371
399
|
if (code > 0 && modifier === 5) return { key: { name: String.fromCodePoint(code), ctrl: true }, consumed }
|
|
372
400
|
}
|
|
373
401
|
if (final === '~') {
|
|
374
402
|
const p = Number(param)
|
|
403
|
+
// xterm modifyOtherKeys level 2 (CSI > 4 ; 2 m) is the other spelling
|
|
404
|
+
// of the same idea: CSI 27 ; <mod> ; <code> ~. It arrives here rather
|
|
405
|
+
// than at 'u' on terminals without CSI-u support.
|
|
406
|
+
const mok = /^27;(\d+);(\d+)$/.exec(param)
|
|
407
|
+
if (mok) {
|
|
408
|
+
const modifier = Number(mok[1])
|
|
409
|
+
const code = Number(mok[2])
|
|
410
|
+
if (code === 13) return { key: enterKey(modifier), consumed }
|
|
411
|
+
if (code > 0 && modifier === 5) return { key: { name: String.fromCodePoint(code), ctrl: true }, consumed }
|
|
412
|
+
return { key: { name: 'unknown', sequence: s.slice(0, consumed) }, consumed }
|
|
413
|
+
}
|
|
375
414
|
const map = { 2: 'insert', 3: 'delete', 5: 'pageup', 6: 'pagedown', 7: 'home', 8: 'end' }
|
|
376
415
|
if (map[p]) return { key: { name: map[p] }, consumed }
|
|
377
416
|
if (p >= 11 && p <= 15) return { key: { name: 'f' + (p - 10) }, consumed }
|
|
@@ -397,8 +436,11 @@ export function decodeKey(input) {
|
|
|
397
436
|
const ch = Buffer.from([first]).toString('latin1')
|
|
398
437
|
if (first === 0x1b) return { key: { name: 'escape' }, consumed: 1 }
|
|
399
438
|
if (first === 0x0a) {
|
|
400
|
-
// LF
|
|
401
|
-
//
|
|
439
|
+
// LF is what a terminal that cannot disambiguate sends for Ctrl+Enter, so
|
|
440
|
+
// it routes to the newline path instead of submitting (plain Enter is CR).
|
|
441
|
+
// Ctrl+J is the same byte by definition — it *is* a line feed — so this
|
|
442
|
+
// path cannot separate the two; only CSI-u / modifyOtherKeys can, and the
|
|
443
|
+
// ones that do arrive as `enter` with a ctrl flag above.
|
|
402
444
|
return { key: { name: 'enter', ctrl: true }, consumed: 1 }
|
|
403
445
|
}
|
|
404
446
|
if (first < 0x20 || first === 0x7f) {
|
package/lib/themes.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The palette keys are shared by chrome, transcript, markdown, and diagrams.
|
|
2
|
+
export const DEFAULT_THEME = 'deepseek'
|
|
3
|
+
|
|
4
|
+
export const THEME_IDS = ['deepseek', 'github-dark', 'dracula', 'vscode-dark', 'nord']
|
|
5
|
+
|
|
6
|
+
const deepseek = {
|
|
7
|
+
primary: '4d6bfe', secondary: '6c9cff', accent: '7c9cff',
|
|
8
|
+
error: 'e06c75', warning: 'e8c468', success: '7fd88f', info: '56b6c2',
|
|
9
|
+
text: 'f0f4ff', textMuted: 'a0aac0',
|
|
10
|
+
background: '0a0e18', backgroundPanel: '111a2c', backgroundElement: '1b2740',
|
|
11
|
+
border: '3d4d73', borderSubtle: '2b3a5c',
|
|
12
|
+
markdownHeading: '7c9cff', markdownLinkText: '6c9cff', markdownCode: '7fd88f',
|
|
13
|
+
markdownCodeBlock: 'f0f4ff', markdownBlockQuote: '9fb0d8',
|
|
14
|
+
markdownListItem: '4d6bfe', markdownHorizontalRule: '46547a', codeBg: '111a2c',
|
|
15
|
+
thinking: '9aa6c2', reminder: 'c5bdf7', imageChipBg: 'd97706', imageChipText: '1a0d00',
|
|
16
|
+
compaction: '95d8c0', inbox: '9fd0e8', steering: '9fd0e8', systemPrompt: 'b9c4dd',
|
|
17
|
+
contextSystem: '7fd88f', contextTools: 'e8c468', contextMessages: '56b6c2',
|
|
18
|
+
selection: '3153b8',
|
|
19
|
+
syntaxKeyword: 'c792ea', syntaxString: '9ece8f', syntaxNumber: 'e5b567',
|
|
20
|
+
syntaxComment: '8994b0', syntaxFunction: '82aaff', syntaxType: 'e5c07b',
|
|
21
|
+
tableBorder: '41528a', userPointer: '4d6bfe', assistantLabel: '9aa6c2',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const palettes = {
|
|
25
|
+
deepseek,
|
|
26
|
+
'github-dark': {
|
|
27
|
+
...deepseek,
|
|
28
|
+
primary: '58a6ff', secondary: '79c0ff', accent: '79c0ff',
|
|
29
|
+
error: 'ff7b72', warning: 'e3b341', success: '3fb950', info: '58a6ff',
|
|
30
|
+
text: 'e6edf3', textMuted: 'a0aab5', background: '0d1117',
|
|
31
|
+
backgroundPanel: '161b22', backgroundElement: '21262d', border: '484f58', borderSubtle: '30363d',
|
|
32
|
+
markdownHeading: '79c0ff', markdownLinkText: '58a6ff', markdownCode: 'a5d6ff',
|
|
33
|
+
markdownCodeBlock: 'e6edf3', markdownBlockQuote: 'a0aab5', markdownListItem: '58a6ff',
|
|
34
|
+
markdownHorizontalRule: '484f58', codeBg: '161b22',
|
|
35
|
+
thinking: 'a0aab5', reminder: 'd2a8ff', compaction: '7ee787',
|
|
36
|
+
inbox: '79c0ff', steering: '79c0ff', systemPrompt: 'b1bac4',
|
|
37
|
+
contextSystem: '3fb950', contextTools: 'e3b341', contextMessages: '58a6ff',
|
|
38
|
+
selection: '264f78', syntaxKeyword: 'ff7b72', syntaxString: 'a5d6ff',
|
|
39
|
+
syntaxNumber: '79c0ff', syntaxComment: '8b949e', syntaxFunction: 'd2a8ff',
|
|
40
|
+
syntaxType: 'ffa657', tableBorder: '484f58', userPointer: '58a6ff', assistantLabel: 'a0aab5',
|
|
41
|
+
},
|
|
42
|
+
dracula: {
|
|
43
|
+
...deepseek,
|
|
44
|
+
primary: 'bd93f9', secondary: '8be9fd', accent: 'bd93f9',
|
|
45
|
+
error: 'ff5555', warning: 'f1fa8c', success: '50fa7b', info: '8be9fd',
|
|
46
|
+
text: 'f8f8f2', textMuted: 'b0b4ca', background: '191a24',
|
|
47
|
+
backgroundPanel: '222430', backgroundElement: '2c2f3e', border: '6272a4', borderSubtle: '50566f',
|
|
48
|
+
markdownHeading: 'bd93f9', markdownLinkText: '8be9fd', markdownCode: '50fa7b',
|
|
49
|
+
markdownCodeBlock: 'f8f8f2', markdownBlockQuote: 'b0b4ca', markdownListItem: 'bd93f9',
|
|
50
|
+
markdownHorizontalRule: '6272a4', codeBg: '222430',
|
|
51
|
+
thinking: 'b0b4ca', reminder: 'ff79c6', compaction: '50fa7b',
|
|
52
|
+
inbox: '8be9fd', steering: '8be9fd', systemPrompt: 'c2c7df',
|
|
53
|
+
contextSystem: '50fa7b', contextTools: 'f1fa8c', contextMessages: '8be9fd',
|
|
54
|
+
selection: '333a50', syntaxKeyword: 'ff79c6', syntaxString: 'f1fa8c',
|
|
55
|
+
syntaxNumber: 'bd93f9', syntaxComment: '9ba7d0', syntaxFunction: '50fa7b',
|
|
56
|
+
syntaxType: '8be9fd', tableBorder: '6272a4', userPointer: 'bd93f9', assistantLabel: 'b0b4ca',
|
|
57
|
+
},
|
|
58
|
+
'vscode-dark': {
|
|
59
|
+
...deepseek,
|
|
60
|
+
primary: '3794ff', secondary: '9cdcfe', accent: '4fc1ff',
|
|
61
|
+
error: 'f48771', warning: 'cca700', success: '89d185', info: '75beff',
|
|
62
|
+
text: 'd4d4d4', textMuted: 'a8a8a8', background: '1e1e1e',
|
|
63
|
+
backgroundPanel: '252526', backgroundElement: '333333', border: '555555', borderSubtle: '3c3c3c',
|
|
64
|
+
markdownHeading: '569cd6', markdownLinkText: '4fc1ff', markdownCode: 'ce9178',
|
|
65
|
+
markdownCodeBlock: 'd4d4d4', markdownBlockQuote: 'a8a8a8', markdownListItem: '3794ff',
|
|
66
|
+
markdownHorizontalRule: '555555', codeBg: '252526',
|
|
67
|
+
thinking: 'a8a8a8', reminder: 'c586c0', compaction: '89d185',
|
|
68
|
+
inbox: '9cdcfe', steering: '9cdcfe', systemPrompt: 'bcbcbc',
|
|
69
|
+
contextSystem: '89d185', contextTools: 'cca700', contextMessages: '75beff',
|
|
70
|
+
selection: '264f78', syntaxKeyword: 'c586c0', syntaxString: 'ce9178',
|
|
71
|
+
syntaxNumber: 'b5cea8', syntaxComment: '8aaa80', syntaxFunction: 'dcdcaa',
|
|
72
|
+
syntaxType: '4ec9b0', tableBorder: '555555', userPointer: '3794ff', assistantLabel: 'a8a8a8',
|
|
73
|
+
},
|
|
74
|
+
nord: {
|
|
75
|
+
...deepseek,
|
|
76
|
+
primary: '88c0d0', secondary: '81a1c1', accent: '8fbcbb',
|
|
77
|
+
error: 'bf616a', warning: 'ebcb8b', success: 'a3be8c', info: '88c0d0',
|
|
78
|
+
text: 'eceff4', textMuted: 'b0bccd', background: '191f29',
|
|
79
|
+
backgroundPanel: '252c39', backgroundElement: '2b3443', border: '677994', borderSubtle: '4c566a',
|
|
80
|
+
markdownHeading: '88c0d0', markdownLinkText: '8fbcbb', markdownCode: 'a3be8c',
|
|
81
|
+
markdownCodeBlock: 'eceff4', markdownBlockQuote: 'b0bccd', markdownListItem: '88c0d0',
|
|
82
|
+
markdownHorizontalRule: '677994', codeBg: '252c39',
|
|
83
|
+
thinking: 'b0bccd', reminder: 'b48ead', compaction: 'a3be8c',
|
|
84
|
+
inbox: '88c0d0', steering: '88c0d0', systemPrompt: 'd8dee9',
|
|
85
|
+
contextSystem: 'a3be8c', contextTools: 'ebcb8b', contextMessages: '88c0d0',
|
|
86
|
+
selection: '344253', syntaxKeyword: '81a1c1', syntaxString: 'a3be8c',
|
|
87
|
+
syntaxNumber: 'b48ead', syntaxComment: 'a0afc3', syntaxFunction: '88c0d0',
|
|
88
|
+
syntaxType: '8fbcbb', tableBorder: '677994', userPointer: '88c0d0', assistantLabel: 'b0bccd',
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const palette of Object.values(palettes)) Object.freeze(palette)
|
|
93
|
+
export const THEME = palettes[DEFAULT_THEME]
|
|
94
|
+
|
|
95
|
+
export function themeId(value) {
|
|
96
|
+
return typeof value === 'string' && Object.hasOwn(palettes, value) ? value : DEFAULT_THEME
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getTheme(value) {
|
|
100
|
+
return palettes[themeId(value)]
|
|
101
|
+
}
|