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.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
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
+ }
@@ -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 centered vertically and horizontally
31
- * - Workspace/git info and model below the mark
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 - Last row the splash may use (the composer claims the rest)
41
- * @param {object} area - Horizontal band the splash centers in: { x0, width }.
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
- // Center the brand vertically (slightly above center for better balance)
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(theme.primary, BRAND_TAIL, t), bold: true, bg: theme.background })
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
- // The shortcut row anchors to the bottom of the usable area; everything
67
- // above it stacks out of the brand and has to stop short of that row.
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 < shortcutsY - 2) {
75
- const info = `${workingDirectory}${gitBranch ? ` git: ${gitBranch}` : ''}`
76
- screen.text(centerX(info), infoY, info, { fg: theme.textMuted, bg: theme.background })
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 is the more valuable of the two, and the branch
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 > hintY + 2) {
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
- export const name = 'dsh-oc-tui/startup'
10
- export const inject = ['cmdlineArgs']
11
- export const TUI_STARTUP_SERVICE = 'tuiStartup'
12
-
13
- // The command line is parsed before the plugin applies, so the settings file is
14
- // not readable here: the help text follows the environment (DSH_TUI_LOCALE, or
15
- // the OS language) and the boot itself then re-resolves the stored preference.
16
- try { setLocale(resolveLocale('system')) } catch { /* the help falls back to English */ }
17
-
18
- function tuiCommand() {
19
- return new Command()
20
- .name('dsh --profile tui')
21
- .description(t('startup.description'))
22
- .helpOption('-h, --help', t('startup.helpOption'))
23
- .option('--resume <sessionId>', t('startup.resume'))
24
- .option('--model <modelId>', t('startup.model'))
25
- .option('--provider <provider>', t('startup.provider'))
26
- .option('--sidebar', t('startup.sidebar'))
27
- .option('--no-sidebar', t('startup.noSidebar'))
28
- .addHelpText('after', `
29
- ${t('startup.examples')}
30
- dsh --profile tui ${t('startup.exampleFresh')}
31
- dsh --profile tui --resume abc123 ${t('startup.exampleResume')}
32
- dsh --profile tui --model deepseek-v4-flash
33
- `)
34
- }
35
-
36
- export function apply(ctx) {
37
- const program = tuiCommand()
38
- program.action(() => {
39
- const options = program.opts()
40
- ctx.provide(TUI_STARTUP_SERVICE, {
41
- resume: options.resume ?? null,
42
- model: options.model ?? null,
43
- provider: options.provider ?? null,
44
- sidebar: options.sidebar !== false,
45
- })
46
- })
47
- parseCmdline(ctx, program)
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: over.fg ?? base.fg ?? null,
39
- bg: over.bg ?? base.bg ?? null,
40
- bold: over.bold ?? base.bold ?? false,
41
- dim: over.dim ?? base.dim ?? false,
42
- italic: over.italic ?? base.italic ?? false,
43
- underline: over.underline ?? base.underline ?? false,
44
- link: over.link ?? base.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 && (modifier === 2 || modifier === 3)) return { key: { name: 'enter', shift: true }, consumed }
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 = Ctrl+J / Ctrl+Enter in raw mode. Route it to the newline path so
401
- // Ctrl+Enter inserts a line break instead of submitting (Enter is CR).
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
+ }