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
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.2.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
+ }
@@ -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
- const record = (name, text) => {
53
- if (!text) return
54
- state.count++
55
- // Stamping each write rather than each line keeps multi-line stack traces
56
- // attached to the moment they happened without parsing them here.
57
- const entry = `[${new Date().toISOString()}] [${name}] ${text}`
58
- if (logPath) {
59
- const line = entry.endsWith('\n') ? entry : entry + '\n'
60
- try {
61
- fs.mkdirSync(path.dirname(logPath), { recursive: true })
62
- if (logBytes === null) {
63
- try { logBytes = fs.statSync(logPath).size } catch { logBytes = 0 }
64
- }
65
- // Rotate by truncation: this file exists to answer "what did the host
66
- // complain about just now", so the newest lines are the ones worth
67
- // keeping.
68
- if (logBytes > MAX_LOG_BYTES) {
69
- fs.writeFileSync(logPath, '')
70
- logBytes = 0
71
- }
72
- fs.appendFileSync(logPath, line)
73
- logBytes += Buffer.byteLength(line)
74
- } catch {
75
- // A read-only home directory must not take the TUI down with it; the
76
- // in-memory copy below still carries the text to restore().
77
- }
78
- }
79
- if (state.bytes < MAX_KEPT_BYTES) {
80
- state.kept.push(text)
81
- state.bytes += Buffer.byteLength(text)
82
- } else {
83
- state.truncated = true
84
- }
85
- if (state.count === 1 && typeof onFirst === 'function') {
86
- try { onFirst() } catch { /* a listener must not break the capture */ }
87
- }
88
- }
89
-
90
- for (const { stream, name } of streams) {
91
- const original = stream.write
92
- if (typeof original !== 'function') continue
93
- stream.write = function (chunk, encoding, callback) {
94
- // Diverted writes report success and still fire the callback: a writer
95
- // that waits for it would otherwise hang the host mid-turn.
96
- record(name, streamText(chunk, encoding))
97
- if (typeof encoding === 'function') encoding()
98
- else if (typeof callback === 'function') callback()
99
- return true
100
- }
101
- undo.push(() => { stream.write = original })
102
- }
103
-
104
- return {
105
- get count() { return state.count },
106
- get truncated() { return state.truncated },
107
- // Everything captured, in write order, for the post-exit replay.
108
- text() { return state.kept.join('') },
109
- restore() {
110
- if (state.restored) return
111
- state.restored = true
112
- for (let i = undo.length - 1; i >= 0; i--) undo[i]()
113
- // Replay through the real streams, now that they no longer paint over the
114
- // alt screen. Nothing here goes through record().
115
- if (state.kept.length > 0) {
116
- try { process.stderr.write(state.kept.join('')) } catch { /* closed on shutdown */ }
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
+ }