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
package/dsh-plugin.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://raw.githubusercontent.com/Yan-Zero/dsh-std/main/packages/manifest/schema/dsh-plugin-0.15.schema.json",
|
|
3
|
-
"manifestVersion": "0.15",
|
|
4
|
-
"id": "io.github.rayafriandion.dsh-oc-tui",
|
|
5
|
-
"name": "dsh-oc-tui",
|
|
6
|
-
"version": "0.
|
|
7
|
-
"license": "LGPL-3.0-or-later",
|
|
8
|
-
"source": { "repository": "https://github.com/rayafriandion/dsh-oc-tui" },
|
|
9
|
-
"facets": { "host": { "entry": "lib/facet.js", "apiVersion": "v1alpha1" } },
|
|
10
|
-
"requires": {
|
|
11
|
-
"contracts": [
|
|
12
|
-
{ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "optional": true }
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
|
-
"permissions": [
|
|
16
|
-
{ "name": "storage.local.read", "scope": "io.github.rayafriandion.dsh-oc-tui",
|
|
17
|
-
"reason": "Read TUI settings from the host settings store." },
|
|
18
|
-
{ "name": "storage.local.write", "scope": "io.github.rayafriandion.dsh-oc-tui",
|
|
19
|
-
"reason": "Persist TUI settings chosen in the Settings pages." }
|
|
20
|
-
],
|
|
21
|
-
"contributes": {
|
|
22
|
-
"x-dev.dsh-std.extensions": [
|
|
23
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.settings",
|
|
24
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "settings",
|
|
25
|
-
"spec": { "title": "Open the TUI settings pages",
|
|
26
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
27
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.help",
|
|
28
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "help",
|
|
29
|
-
"spec": { "title": "Show the TUI key and command reference",
|
|
30
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
31
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.stats",
|
|
32
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "stats",
|
|
33
|
-
"spec": { "title": "Show session token and cache statistics",
|
|
34
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
35
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.new",
|
|
36
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "new",
|
|
37
|
-
"spec": { "title": "Start a new session",
|
|
38
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
39
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.resume",
|
|
40
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "resume",
|
|
41
|
-
"spec": { "title": "Resume a persisted session",
|
|
42
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
43
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.clear",
|
|
44
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "clear",
|
|
45
|
-
"spec": { "title": "Clear the transcript",
|
|
46
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
47
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.cancel",
|
|
48
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "cancel",
|
|
49
|
-
"spec": { "title": "Cancel the running turn",
|
|
50
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
51
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.rewind",
|
|
52
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "rewind",
|
|
53
|
-
"spec": { "title": "Rewind the session to an earlier point",
|
|
54
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
55
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.branch",
|
|
56
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "branch",
|
|
57
|
-
"spec": { "title": "Switch git branches on the empty page",
|
|
58
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
59
|
-
{ "id": "io.github.rayafriandion.dsh-oc-tui.quit",
|
|
60
|
-
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "quit",
|
|
61
|
-
"spec": { "title": "Leave the TUI", "aliases": ["exit"],
|
|
62
|
-
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } }
|
|
63
|
-
]
|
|
64
|
-
},
|
|
65
|
-
"overrides": [
|
|
66
|
-
{ "target": "@deepseek-ai/dsh-base", "kind": "patch",
|
|
67
|
-
"description": "cordis.patch.yml inserts the tui-startup, tui-app, agent-presets and tool-ask-user rows." }
|
|
68
|
-
]
|
|
69
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/Yan-Zero/dsh-std/main/packages/manifest/schema/dsh-plugin-0.15.schema.json",
|
|
3
|
+
"manifestVersion": "0.15",
|
|
4
|
+
"id": "io.github.rayafriandion.dsh-oc-tui",
|
|
5
|
+
"name": "dsh-oc-tui",
|
|
6
|
+
"version": "0.3.0",
|
|
7
|
+
"license": "LGPL-3.0-or-later",
|
|
8
|
+
"source": { "repository": "https://github.com/rayafriandion/dsh-oc-tui" },
|
|
9
|
+
"facets": { "host": { "entry": "lib/facet.js", "apiVersion": "v1alpha1" } },
|
|
10
|
+
"requires": {
|
|
11
|
+
"contracts": [
|
|
12
|
+
{ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "optional": true }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"permissions": [
|
|
16
|
+
{ "name": "storage.local.read", "scope": "io.github.rayafriandion.dsh-oc-tui",
|
|
17
|
+
"reason": "Read TUI settings from the host settings store." },
|
|
18
|
+
{ "name": "storage.local.write", "scope": "io.github.rayafriandion.dsh-oc-tui",
|
|
19
|
+
"reason": "Persist TUI settings chosen in the Settings pages." }
|
|
20
|
+
],
|
|
21
|
+
"contributes": {
|
|
22
|
+
"x-dev.dsh-std.extensions": [
|
|
23
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.settings",
|
|
24
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "settings",
|
|
25
|
+
"spec": { "title": "Open the TUI settings pages",
|
|
26
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
27
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.help",
|
|
28
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "help",
|
|
29
|
+
"spec": { "title": "Show the TUI key and command reference",
|
|
30
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
31
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.stats",
|
|
32
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "stats",
|
|
33
|
+
"spec": { "title": "Show session token and cache statistics",
|
|
34
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
35
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.new",
|
|
36
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "new",
|
|
37
|
+
"spec": { "title": "Start a new session",
|
|
38
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
39
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.resume",
|
|
40
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "resume",
|
|
41
|
+
"spec": { "title": "Resume a persisted session",
|
|
42
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
43
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.clear",
|
|
44
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "clear",
|
|
45
|
+
"spec": { "title": "Clear the transcript",
|
|
46
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
47
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.cancel",
|
|
48
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "cancel",
|
|
49
|
+
"spec": { "title": "Cancel the running turn",
|
|
50
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
51
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.rewind",
|
|
52
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "rewind",
|
|
53
|
+
"spec": { "title": "Rewind the session to an earlier point",
|
|
54
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
55
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.branch",
|
|
56
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "branch",
|
|
57
|
+
"spec": { "title": "Switch git branches on the empty page",
|
|
58
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
|
|
59
|
+
{ "id": "io.github.rayafriandion.dsh-oc-tui.quit",
|
|
60
|
+
"apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "quit",
|
|
61
|
+
"spec": { "title": "Leave the TUI", "aliases": ["exit"],
|
|
62
|
+
"placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"overrides": [
|
|
66
|
+
{ "target": "@deepseek-ai/dsh-base", "kind": "patch",
|
|
67
|
+
"description": "cordis.patch.yml inserts the tui-startup, tui-app, agent-presets and tool-ask-user rows." }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The page a boot refuses on.
|
|
2
|
+
//
|
|
3
|
+
// It is painted *instead of* the interface, never on top of it, because the
|
|
4
|
+
// whole point is that nothing else may happen: no session, no settings, no
|
|
5
|
+
// startup update check. The only input it accepts is quit.
|
|
6
|
+
//
|
|
7
|
+
// A card, not a toast: the failure is not something to dismiss and continue
|
|
8
|
+
// past, and the information it carries (what is running, what was verified, what
|
|
9
|
+
// to install) has to stay on screen while the reader copies a command out of it.
|
|
10
|
+
import { Screen, makeStyle } from './term.js'
|
|
11
|
+
import { displayWidth, truncateWidth, wrapText } from './util.js'
|
|
12
|
+
import { t as tr } from './i18n.js'
|
|
13
|
+
|
|
14
|
+
const PAD = 2
|
|
15
|
+
const MAX_WIDTH = 78
|
|
16
|
+
|
|
17
|
+
// Build the frame for the gate page. The caller owns the terminal, so this stays
|
|
18
|
+
// a pure (cols, rows) -> Screen function with no knowledge of it.
|
|
19
|
+
export function renderBootBlock(cols, rows, theme, info) {
|
|
20
|
+
return paintBootBlock(new Screen(cols, rows), theme, info)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Paint the version-gate page.
|
|
25
|
+
* @param {Screen} screen - the frame to paint into
|
|
26
|
+
* @param {object} theme - active colour theme
|
|
27
|
+
* @param {object} info - gate result: { verdict, version, range, recommended, tested, source }
|
|
28
|
+
*/
|
|
29
|
+
export function paintBootBlock(screen, theme, info) {
|
|
30
|
+
const background = makeStyle({ bg: theme.background })
|
|
31
|
+
screen.clear(background)
|
|
32
|
+
|
|
33
|
+
const width = Math.max(24, Math.min(screen.cols - PAD * 2, MAX_WIDTH))
|
|
34
|
+
const x0 = Math.max(1, Math.floor((screen.cols - width) / 2))
|
|
35
|
+
const inner = Math.max(12, width - PAD)
|
|
36
|
+
|
|
37
|
+
const title = tr(info.verdict === 'unsupported' ? 'boot.unsupportedTitle' : 'boot.unverifiedTitle')
|
|
38
|
+
const headline = { fg: theme.error, bold: true }
|
|
39
|
+
const label = { fg: theme.textMuted }
|
|
40
|
+
const value = { fg: theme.text }
|
|
41
|
+
const body = { fg: theme.textMuted }
|
|
42
|
+
const command = { fg: theme.primary, bold: true }
|
|
43
|
+
const hint = { fg: theme.textMuted, dim: true }
|
|
44
|
+
|
|
45
|
+
// The card is assembled from parts so a short terminal can drop the narration
|
|
46
|
+
// and keep the thing the page exists for: the command that fixes it. What is
|
|
47
|
+
// running and how to install the recommended version are essential; the
|
|
48
|
+
// alternatives go first, and each one only comes back if it still fits.
|
|
49
|
+
const rows = []
|
|
50
|
+
const blank = () => rows.push(null)
|
|
51
|
+
// A field wraps at the value column rather than truncating: the verified list is
|
|
52
|
+
// the one row that routinely outgrows the card, and cutting it with `…` hides
|
|
53
|
+
// versions the reader is being asked to choose between.
|
|
54
|
+
const field = (name, text, style = value) => {
|
|
55
|
+
const pad = displayWidth(name) < 12 ? ' '.repeat(12 - displayWidth(name)) : ' '
|
|
56
|
+
const width = Math.max(4, inner - 12)
|
|
57
|
+
const lines = wrapText(text, width)
|
|
58
|
+
rows.push([{ text: name + pad, style: label }, { text: lines[0] ?? '', style }])
|
|
59
|
+
for (const line of lines.slice(1)) rows.push([{ text: ' '.repeat(12), style: null }, { text: line, style }])
|
|
60
|
+
}
|
|
61
|
+
const wrap = (text, style = body) => {
|
|
62
|
+
for (const line of wrapText(text, inner)) rows.push([{ text: line, style }])
|
|
63
|
+
}
|
|
64
|
+
// Everything below pushes into `rows`; each splice cuts the block back out so
|
|
65
|
+
// the parts can be ordered and dropped independently of how they were written.
|
|
66
|
+
const cut = () => rows.splice(0)
|
|
67
|
+
|
|
68
|
+
rows.push([{ text: '⚠ ', style: headline }, { text: title, style: headline }])
|
|
69
|
+
blank()
|
|
70
|
+
field(tr('boot.runningLabel'), info.version ?? '—')
|
|
71
|
+
field(tr('boot.supportedLabel'), info.range === '' ? '—' : info.range)
|
|
72
|
+
if (info.recommended) field(tr('boot.recommendedLabel'), info.recommended, { fg: theme.success, bold: true })
|
|
73
|
+
if (info.tested?.length) field(tr('boot.verifiedLabel'), info.tested.join(' · '))
|
|
74
|
+
blank()
|
|
75
|
+
const head = cut()
|
|
76
|
+
|
|
77
|
+
wrap(tr(info.verdict === 'unsupported' ? 'boot.unsupportedBody' : 'boot.unverifiedBody', {
|
|
78
|
+
version: info.version ?? '?',
|
|
79
|
+
range: info.range,
|
|
80
|
+
recommended: info.recommended ?? '',
|
|
81
|
+
verified: (info.tested ?? []).join(' · '),
|
|
82
|
+
}))
|
|
83
|
+
blank()
|
|
84
|
+
const explanation = cut()
|
|
85
|
+
|
|
86
|
+
wrap(tr('boot.installHint'))
|
|
87
|
+
const installHint = cut()
|
|
88
|
+
|
|
89
|
+
rows.push([{ text: ' npm i -g @deepseek-ai/dsh@' + (info.recommended ?? ''), style: command }])
|
|
90
|
+
blank()
|
|
91
|
+
const installCommand = cut()
|
|
92
|
+
|
|
93
|
+
wrap(tr('boot.updatePluginHint'))
|
|
94
|
+
rows.push([{ text: ' npm i -g dsh-oc-tui@latest', style: command }])
|
|
95
|
+
const alternative = cut()
|
|
96
|
+
|
|
97
|
+
// The bottom row is the footer, so the card is centred in the rows above it.
|
|
98
|
+
// Reading order is fixed by this list; membership is what the budget decides.
|
|
99
|
+
const footerRow = Math.max(0, screen.rows - 1)
|
|
100
|
+
const budget = footerRow
|
|
101
|
+
const kept = new Set([head, installCommand])
|
|
102
|
+
const card = () => [head, explanation, installHint, installCommand, alternative]
|
|
103
|
+
.flatMap((part) => (kept.has(part) ? part : []))
|
|
104
|
+
// Added by importance, so a window that only fits one of them keeps the
|
|
105
|
+
// sentence that introduces the command rather than the alternative route.
|
|
106
|
+
for (const part of [installHint, explanation, alternative]) {
|
|
107
|
+
if (card().length + part.length > budget) continue
|
|
108
|
+
kept.add(part)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines = card()
|
|
112
|
+
const top = Math.max(0, Math.floor((budget - lines.length) / 2))
|
|
113
|
+
const limit = Math.max(0, Math.min(lines.length, budget - top))
|
|
114
|
+
for (let i = 0; i < limit; i++) {
|
|
115
|
+
const row = lines[i]
|
|
116
|
+
if (!row) continue
|
|
117
|
+
let x = x0
|
|
118
|
+
for (const segment of row) {
|
|
119
|
+
x = screen.text(x, top + i, segment.text, makeStyle({ ...segment.style, bg: theme.background }))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// The one line that must survive any window height: how to get out. It is a
|
|
123
|
+
// footer, not a card row, so a tall card being truncated cannot swallow it —
|
|
124
|
+
// and it clears its row first, because a card that ran into it would otherwise
|
|
125
|
+
// leave its own text trailing after the hint.
|
|
126
|
+
screen.fill(0, footerRow, screen.cols, ' ', background)
|
|
127
|
+
screen.text(x0, footerRow, truncateWidth(tr('boot.exitHint'), inner), makeStyle({ ...hint, bg: theme.background }))
|
|
128
|
+
return screen
|
|
129
|
+
}
|
package/lib/diagnostics.js
CHANGED
|
@@ -1,120 +1,162 @@
|
|
|
1
|
-
// Host diagnostics captured while the TUI owns the alternate screen.
|
|
2
|
-
//
|
|
3
|
-
// Plugins in this process report through `console.warn` / `console.error` when
|
|
4
|
-
// no logger exporter is mounted — the LLM adapters do exactly that for model
|
|
5
|
-
// listing problems. On an ordinary CLI that is fine; here every one of those
|
|
6
|
-
// writes lands on the alt screen the renderer believes it owns, leaving
|
|
7
|
-
// unpainted text behind that the diffing painter never corrects.
|
|
8
|
-
//
|
|
9
|
-
// So the process streams are diverted for as long as the alt screen is up. The
|
|
10
|
-
// text is appended to a log file the user can be pointed at, and replayed to the
|
|
11
|
-
// real stderr on restore, after the alt screen is gone and it can do no harm.
|
|
12
|
-
import fs from 'node:fs'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
|
|
15
|
-
// Beyond this the in-memory copy stops growing: a plugin stuck in a warn loop
|
|
16
|
-
// must not be able to exhaust the heap. The file keeps the full text either way.
|
|
17
|
-
const MAX_KEPT_BYTES = 256 * 1024
|
|
18
|
-
// The log is truncated once it passes this. A plugin that warns on every frame
|
|
19
|
-
// would otherwise grow the file without bound across a long session.
|
|
20
|
-
const MAX_LOG_BYTES = 1024 * 1024
|
|
21
|
-
|
|
22
|
-
function streamText(chunk, encoding) {
|
|
23
|
-
if (Buffer.isBuffer(chunk)) return chunk.toString('utf8')
|
|
24
|
-
return typeof chunk === 'string' ? chunk : String(chunk)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Intercept both process streams. Returns a handle with the captured text, a
|
|
28
|
-
// live count, and `restore()`.
|
|
29
|
-
//
|
|
30
|
-
// `onFirst` fires exactly once, on the first diverted write: the caller uses it
|
|
31
|
-
// for a single toast rather than one per line. It is called after the write has
|
|
32
|
-
// been recorded, never from inside a console method, so a listener that itself
|
|
33
|
-
// logs cannot recurse into the capture.
|
|
34
|
-
export function captureDiagnostics({ logPath, onFirst } = {}) {
|
|
35
|
-
const streams = [
|
|
36
|
-
{ stream: process.stdout, name: 'stdout' },
|
|
37
|
-
{ stream: process.stderr, name: 'stderr' },
|
|
38
|
-
]
|
|
39
|
-
const undo = []
|
|
40
|
-
const state = {
|
|
41
|
-
count: 0,
|
|
42
|
-
bytes: 0,
|
|
43
|
-
kept: [],
|
|
44
|
-
truncated: false,
|
|
45
|
-
restored: false,
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Size is read once and then tracked, so a quiet session with no host output
|
|
49
|
-
// never touches the filesystem at all.
|
|
50
|
-
let logBytes = null
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
text
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
1
|
+
// Host diagnostics captured while the TUI owns the alternate screen.
|
|
2
|
+
//
|
|
3
|
+
// Plugins in this process report through `console.warn` / `console.error` when
|
|
4
|
+
// no logger exporter is mounted — the LLM adapters do exactly that for model
|
|
5
|
+
// listing problems. On an ordinary CLI that is fine; here every one of those
|
|
6
|
+
// writes lands on the alt screen the renderer believes it owns, leaving
|
|
7
|
+
// unpainted text behind that the diffing painter never corrects.
|
|
8
|
+
//
|
|
9
|
+
// So the process streams are diverted for as long as the alt screen is up. The
|
|
10
|
+
// text is appended to a log file the user can be pointed at, and replayed to the
|
|
11
|
+
// real stderr on restore, after the alt screen is gone and it can do no harm.
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
// Beyond this the in-memory copy stops growing: a plugin stuck in a warn loop
|
|
16
|
+
// must not be able to exhaust the heap. The file keeps the full text either way.
|
|
17
|
+
const MAX_KEPT_BYTES = 256 * 1024
|
|
18
|
+
// The log is truncated once it passes this. A plugin that warns on every frame
|
|
19
|
+
// would otherwise grow the file without bound across a long session.
|
|
20
|
+
const MAX_LOG_BYTES = 1024 * 1024
|
|
21
|
+
|
|
22
|
+
function streamText(chunk, encoding) {
|
|
23
|
+
if (Buffer.isBuffer(chunk)) return chunk.toString('utf8')
|
|
24
|
+
return typeof chunk === 'string' ? chunk : String(chunk)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Intercept both process streams. Returns a handle with the captured text, a
|
|
28
|
+
// live count, and `restore()`.
|
|
29
|
+
//
|
|
30
|
+
// `onFirst` fires exactly once, on the first diverted write: the caller uses it
|
|
31
|
+
// for a single toast rather than one per line. It is called after the write has
|
|
32
|
+
// been recorded, never from inside a console method, so a listener that itself
|
|
33
|
+
// logs cannot recurse into the capture.
|
|
34
|
+
export function captureDiagnostics({ logPath, onFirst } = {}) {
|
|
35
|
+
const streams = [
|
|
36
|
+
{ stream: process.stdout, name: 'stdout' },
|
|
37
|
+
{ stream: process.stderr, name: 'stderr' },
|
|
38
|
+
]
|
|
39
|
+
const undo = []
|
|
40
|
+
const state = {
|
|
41
|
+
count: 0,
|
|
42
|
+
bytes: 0,
|
|
43
|
+
kept: [],
|
|
44
|
+
truncated: false,
|
|
45
|
+
restored: false,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Size is read once and then tracked, so a quiet session with no host output
|
|
49
|
+
// never touches the filesystem at all.
|
|
50
|
+
let logBytes = null
|
|
51
|
+
let directoryReady = false
|
|
52
|
+
// The log is written in batches, not once per diverted write. `record` runs
|
|
53
|
+
// inside the host's own write path, and an MCP server started through `npx`
|
|
54
|
+
// prints its download progress there in hundreds of small chunks: a
|
|
55
|
+
// synchronous append, a stat and a mkdir per chunk blocked the frame loop long
|
|
56
|
+
// enough to read as a stutter — worst of all while MCP servers were loading,
|
|
57
|
+
// which is exactly when that output arrives. The in-memory copy stays exact
|
|
58
|
+
// and immediate; only the file lags by one flush interval.
|
|
59
|
+
const FLUSH_MS = 250
|
|
60
|
+
const FLUSH_BYTES = 64 * 1024
|
|
61
|
+
let pending = ''
|
|
62
|
+
let flushTimer = null
|
|
63
|
+
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (flushTimer) {
|
|
66
|
+
clearTimeout(flushTimer)
|
|
67
|
+
flushTimer = null
|
|
68
|
+
}
|
|
69
|
+
if (!logPath || pending === '') return
|
|
70
|
+
const text = pending
|
|
71
|
+
pending = ''
|
|
72
|
+
try {
|
|
73
|
+
if (!directoryReady) {
|
|
74
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
|
75
|
+
directoryReady = true
|
|
76
|
+
}
|
|
77
|
+
if (logBytes === null) {
|
|
78
|
+
try { logBytes = fs.statSync(logPath).size } catch { logBytes = 0 }
|
|
79
|
+
}
|
|
80
|
+
// Rotate by truncation: this file exists to answer "what did the host
|
|
81
|
+
// complain about just now", so the newest lines are the ones worth
|
|
82
|
+
// keeping.
|
|
83
|
+
if (logBytes + Buffer.byteLength(text) > MAX_LOG_BYTES) {
|
|
84
|
+
fs.writeFileSync(logPath, '')
|
|
85
|
+
logBytes = 0
|
|
86
|
+
}
|
|
87
|
+
fs.appendFileSync(logPath, text)
|
|
88
|
+
logBytes += Buffer.byteLength(text)
|
|
89
|
+
} catch {
|
|
90
|
+
// A read-only home directory must not take the TUI down with it; the
|
|
91
|
+
// in-memory copy below still carries the text to restore().
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const queueForLog = (line) => {
|
|
96
|
+
pending += line
|
|
97
|
+
if (Buffer.byteLength(pending) >= FLUSH_BYTES) {
|
|
98
|
+
flush()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (flushTimer) return
|
|
102
|
+
flushTimer = setTimeout(flush, FLUSH_MS)
|
|
103
|
+
// The timer must never be the reason the process stays alive.
|
|
104
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const record = (name, text) => {
|
|
108
|
+
if (!text) return
|
|
109
|
+
state.count++
|
|
110
|
+
// Stamping each write rather than each line keeps multi-line stack traces
|
|
111
|
+
// attached to the moment they happened without parsing them here.
|
|
112
|
+
const entry = `[${new Date().toISOString()}] [${name}] ${text}`
|
|
113
|
+
if (logPath) queueForLog(entry.endsWith('\n') ? entry : entry + '\n')
|
|
114
|
+
if (state.bytes < MAX_KEPT_BYTES) {
|
|
115
|
+
state.kept.push(text)
|
|
116
|
+
state.bytes += Buffer.byteLength(text)
|
|
117
|
+
} else {
|
|
118
|
+
state.truncated = true
|
|
119
|
+
}
|
|
120
|
+
if (state.count === 1 && typeof onFirst === 'function') {
|
|
121
|
+
try { onFirst() } catch { /* a listener must not break the capture */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const { stream, name } of streams) {
|
|
126
|
+
const original = stream.write
|
|
127
|
+
if (typeof original !== 'function') continue
|
|
128
|
+
stream.write = function (chunk, encoding, callback) {
|
|
129
|
+
// Diverted writes report success and still fire the callback: a writer
|
|
130
|
+
// that waits for it would otherwise hang the host mid-turn.
|
|
131
|
+
record(name, streamText(chunk, encoding))
|
|
132
|
+
if (typeof encoding === 'function') encoding()
|
|
133
|
+
else if (typeof callback === 'function') callback()
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
undo.push(() => { stream.write = original })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
get count() { return state.count },
|
|
141
|
+
get truncated() { return state.truncated },
|
|
142
|
+
// Everything captured, in write order, for the post-exit replay.
|
|
143
|
+
text() { return state.kept.join('') },
|
|
144
|
+
// Write the log out now instead of on the next flush tick. A caller that
|
|
145
|
+
// is about to read the file — the tests, a user asked to look at it —
|
|
146
|
+
// needs the tail to be on disk already.
|
|
147
|
+
flush,
|
|
148
|
+
restore() {
|
|
149
|
+
if (state.restored) return
|
|
150
|
+
state.restored = true
|
|
151
|
+
// Pending log text would otherwise be lost on the way out, which is
|
|
152
|
+
// exactly the tail a user is asked to look at when something failed.
|
|
153
|
+
flush()
|
|
154
|
+
for (let i = undo.length - 1; i >= 0; i--) undo[i]()
|
|
155
|
+
// Replay through the real streams, now that they no longer paint over the
|
|
156
|
+
// alt screen. Nothing here goes through record().
|
|
157
|
+
if (state.kept.length > 0) {
|
|
158
|
+
try { process.stderr.write(state.kept.join('')) } catch { /* closed on shutdown */ }
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|