termux-dev 1.6.0 → 1.8.1

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 (99) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +101 -10
  5. package/dist/cli/banner.js +3 -4
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/finder.js +241 -0
  15. package/dist/cli/headless.js +23 -11
  16. package/dist/cli/index.js +1557 -744
  17. package/dist/cli/keys.js +69 -0
  18. package/dist/cli/liveblock.js +45 -0
  19. package/dist/cli/log.js +19 -0
  20. package/dist/cli/logo.js +30 -0
  21. package/dist/cli/markdown.js +174 -37
  22. package/dist/cli/mcp-panel.js +432 -0
  23. package/dist/cli/permission.js +84 -0
  24. package/dist/cli/picker.js +143 -0
  25. package/dist/cli/prompt.js +256 -56
  26. package/dist/cli/resume.js +15 -0
  27. package/dist/cli/rewind.js +51 -0
  28. package/dist/cli/runtime_errors.js +6 -0
  29. package/dist/cli/server.js +85 -24
  30. package/dist/cli/settings.js +307 -0
  31. package/dist/cli/statusline.js +1 -1
  32. package/dist/cli/theme.js +126 -108
  33. package/dist/cli/transcript.js +134 -0
  34. package/dist/cli/typeahead.js +371 -0
  35. package/dist/cli/ui.js +1022 -0
  36. package/dist/cli/updater.js +23 -21
  37. package/dist/cli/usage-panel.js +242 -0
  38. package/dist/cli/welcome.js +56 -0
  39. package/dist/cli/width.js +178 -0
  40. package/dist/core/capabilities.js +433 -0
  41. package/dist/core/catalog-util.js +27 -0
  42. package/dist/core/checkpoint.js +90 -0
  43. package/dist/core/context-stats.js +29 -0
  44. package/dist/core/cost.js +191 -0
  45. package/dist/core/formats.js +199 -0
  46. package/dist/core/history.js +51 -1
  47. package/dist/core/hooks.js +79 -0
  48. package/dist/core/i18n.js +42 -0
  49. package/dist/core/local-config.js +69 -0
  50. package/dist/core/locales/de.js +265 -0
  51. package/dist/core/locales/en.js +276 -0
  52. package/dist/core/locales/es.js +265 -0
  53. package/dist/core/locales/fr.js +265 -0
  54. package/dist/core/locales/ja.js +265 -0
  55. package/dist/core/locales/pt.js +265 -0
  56. package/dist/core/locales/ru.js +265 -0
  57. package/dist/core/locales/uk.js +265 -0
  58. package/dist/core/locales/zh.js +265 -0
  59. package/dist/core/loop.js +125 -49
  60. package/dist/core/memory.js +39 -12
  61. package/dist/core/models.js +33 -20
  62. package/dist/core/network.js +12 -11
  63. package/dist/core/notify.js +54 -0
  64. package/dist/core/planfile.js +43 -0
  65. package/dist/core/price-catalog.json +1 -0
  66. package/dist/core/prices.js +249 -0
  67. package/dist/core/reasoning-catalog.json +1 -0
  68. package/dist/core/reasoning.js +393 -0
  69. package/dist/core/redact.js +41 -0
  70. package/dist/core/secure-file.js +22 -0
  71. package/dist/core/session.js +16 -5
  72. package/dist/core/snapshot.js +14 -2
  73. package/dist/core/title.js +61 -0
  74. package/dist/core/types.js +1 -1
  75. package/dist/core/usage.js +43 -7
  76. package/dist/core/vision-catalog.json +1 -0
  77. package/dist/core/vision.js +123 -0
  78. package/dist/mcp/client.js +36 -5
  79. package/dist/mcp/http-client.js +174 -0
  80. package/dist/mcp/manager.js +330 -93
  81. package/dist/permissions/guard.js +385 -117
  82. package/dist/prompts/builder.js +46 -38
  83. package/dist/providers/anthropic.js +517 -0
  84. package/dist/providers/index.js +18 -1
  85. package/dist/providers/openai.js +138 -125
  86. package/dist/providers/shared.js +116 -0
  87. package/dist/providers/toolguard.js +101 -0
  88. package/dist/tools/bash.js +141 -51
  89. package/dist/tools/diagnostics.js +76 -48
  90. package/dist/tools/fs.js +77 -5
  91. package/dist/tools/index.js +2 -1
  92. package/dist/tools/mcp.js +72 -0
  93. package/dist/tools/packages.js +16 -1
  94. package/dist/tools/plan.js +8 -8
  95. package/dist/tools/questions.js +50 -54
  96. package/dist/tools/server.js +6 -0
  97. package/dist/tools/todo.js +20 -18
  98. package/dist/tools/web.js +143 -8
  99. package/package.json +1 -1
@@ -0,0 +1,69 @@
1
+ // One terminal "key": a full escape sequence (arrows, Home, Delete...), Alt+Enter, Alt+Backspace, a lone Esc,
2
+ // or a single character. The `u` flag keeps characters outside the BMP in one piece.
3
+ const KEY = /\x1b\[[0-9;:?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b[\r\n\x7f]|\x1b|[\s\S]/gu;
4
+ /**
5
+ * Splits raw input into separate key presses.
6
+ *
7
+ * A terminal may deliver several keys in one chunk (holding an arrow key, a fast typist, a
8
+ * slow connection, Termux batching input), for example "\x1b[B\x1b[B\x1b[B" for three
9
+ * presses of Down. Code that compares a whole chunk with one key would miss them all.
10
+ */
11
+ export function splitKeys(chunk) {
12
+ return chunk.match(KEY) ?? [];
13
+ }
14
+ /** Erases the word before the cursor (Ctrl+W in a shell); Ctrl+Backspace and Alt+Backspace are mapped to it. */
15
+ export const WORD_BACKSPACE = '\x17';
16
+ /** What a terminal sends for Ctrl+Enter once the extended keyboard modes below are on. */
17
+ export const CTRL_ENTER = '\x1b[13;5u';
18
+ /**
19
+ * Asks the terminal to tell Ctrl+Enter apart from Enter (a plain terminal sends the same
20
+ * byte for both). Two protocols are requested, a terminal answers the one it knows:
21
+ * the kitty keyboard protocol (kitty, foot, ghostty, wezterm, alacritty, ...) and xterm's
22
+ * modifyOtherKeys. Terminals that know neither ignore both, and Alt+Enter still works.
23
+ * While on, the keys arrive as `CSI … u`: run them through `normalizeKey`.
24
+ */
25
+ export const EXTENDED_KEYS_ON = '\x1b[>1u\x1b[>4;1m';
26
+ export const EXTENDED_KEYS_OFF = '\x1b[<u\x1b[>4;0m';
27
+ /**
28
+ * Turns the extended encodings back into the keys the rest of the code knows:
29
+ * `CSI 99;5u` and `CSI 27;5;99~` become Ctrl+C (0x03), `CSI 27u` becomes Esc, and every way
30
+ * of sending Ctrl+Enter or Alt+Enter becomes `CTRL_ENTER`. Anything else is returned as is.
31
+ */
32
+ export function normalizeKey(key) {
33
+ if (key === '\x1b\r' || key === '\x1b\n')
34
+ return CTRL_ENTER;
35
+ if (key === '\x1b\x7f')
36
+ return WORD_BACKSPACE; // Alt+Backspace
37
+ const m = /^\x1b\[(?:27;(\d+);(\d+)~|(\d+)(?:;(\d+))?(?::\d+)?u)$/.exec(key);
38
+ if (!m)
39
+ return key;
40
+ // modifyOtherKeys is `27;mods;code`, the kitty protocol is `code;mods`.
41
+ const code = Number(m[2] ?? m[3]);
42
+ const mods = Math.max(0, Number(m[1] ?? m[4] ?? 1) - 1);
43
+ const shift = (mods & 1) !== 0;
44
+ const alt = (mods & 2) !== 0;
45
+ const ctrl = (mods & 4) !== 0;
46
+ // Ctrl+Backspace and Alt+Backspace: erase a word.
47
+ if ((code === 127 || code === 8) && (ctrl || alt))
48
+ return WORD_BACKSPACE;
49
+ if (code === 13 || code === 10)
50
+ return ctrl || alt ? CTRL_ENTER : shift ? key : '\r';
51
+ if (alt)
52
+ return key;
53
+ if (ctrl) {
54
+ if (code >= 97 && code <= 122)
55
+ return String.fromCharCode(code - 96);
56
+ if (code >= 65 && code <= 90)
57
+ return String.fromCharCode(code - 64);
58
+ return key;
59
+ }
60
+ if (code === 27)
61
+ return '\x1b';
62
+ if (code === 9)
63
+ return '\t';
64
+ if (code === 127 || code === 8)
65
+ return String.fromCharCode(code);
66
+ if (code >= 32 && code !== 127 && !shift)
67
+ return String.fromCodePoint(code);
68
+ return key;
69
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A block of terminal rows that is redrawn in place.
3
+ *
4
+ * The cursor rests on the last row of the block. A redraw goes back up to the first row,
5
+ * rewrites every row and wipes the rows left over from a taller previous version, so the
6
+ * screen never grows however often the content changes. The cursor is hidden while the
7
+ * block is on screen.
8
+ */
9
+ export class LiveBlock {
10
+ out;
11
+ rows = 0;
12
+ constructor(out) {
13
+ this.out = out;
14
+ }
15
+ get height() {
16
+ return this.rows;
17
+ }
18
+ draw(lines) {
19
+ let buf = '\x1b[?25l';
20
+ if (this.rows > 1)
21
+ buf += `\x1b[${this.rows - 1}A`;
22
+ buf += '\r';
23
+ lines.forEach((line, i) => {
24
+ buf += '\x1b[2K' + line + '\x1b[0m' + (i < lines.length - 1 ? '\n' : '');
25
+ });
26
+ let extra = 0;
27
+ for (let k = lines.length; k < this.rows; k++) {
28
+ buf += '\n\x1b[2K';
29
+ extra++;
30
+ }
31
+ if (extra > 0)
32
+ buf += `\x1b[${extra}A`;
33
+ this.out.write(buf);
34
+ this.rows = lines.length;
35
+ }
36
+ /** Removes the block from the screen and shows the cursor again. */
37
+ erase() {
38
+ if (this.rows === 0) {
39
+ this.out.write('\x1b[?25h');
40
+ return;
41
+ }
42
+ this.out.write(`\r${this.rows > 1 ? `\x1b[${this.rows - 1}A` : ''}\x1b[J\x1b[?25h`);
43
+ this.rows = 0;
44
+ }
45
+ }
@@ -0,0 +1,19 @@
1
+ import { palette, renderNotice } from './ui.js';
2
+ import { stripAnsi } from './width.js';
3
+ const print = (kind, message) => console.log(renderNotice(kind, stripAnsi(message)));
4
+ export const log = {
5
+ info: (message) => print('info', message),
6
+ success: (message) => print('ok', message),
7
+ warn: (message) => print('warn', message),
8
+ error: (message) => print('error', message),
9
+ message: (message) => print('info', message),
10
+ };
11
+ /** A titled block of lines (the command list, the config), rows kept as they are. */
12
+ export function note(text, title = '') {
13
+ console.log();
14
+ if (title)
15
+ console.log(' ' + palette.bold(palette.accent(title)));
16
+ for (const row of stripAnsi(text).split('\n'))
17
+ console.log(' ' + palette.muted(row));
18
+ console.log();
19
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The devx wordmark, drawn from a small pixel font: every letter is 5 pixels wide and 6 tall, and two
3
+ * pixel rows share one terminal row with half blocks, so the pixels are square and the shapes stay clean.
4
+ */
5
+ const FONT = {
6
+ D: ['████ ', '█ █', '█ █', '█ █', '█ █', '████ '],
7
+ E: ['█████', '█ ', '████ ', '█ ', '█ ', '█████'],
8
+ V: ['█ █', '█ █', '█ █', '█ █', ' █ █ ', ' █ '],
9
+ X: ['█ █', ' █ █ ', ' █ ', ' █ ', ' █ █ ', '█ █'],
10
+ };
11
+ export function wordmark(text = 'DEVX', gap = 1) {
12
+ const letters = Array.from(text).map((c) => FONT[c]);
13
+ const rows = letters[0].length;
14
+ const out = [];
15
+ for (let r = 0; r < rows; r += 2) {
16
+ let line = '';
17
+ letters.forEach((glyph, i) => {
18
+ if (i > 0)
19
+ line += ' '.repeat(gap);
20
+ for (let x = 0; x < glyph[r].length; x++) {
21
+ const top = glyph[r][x] !== ' ';
22
+ const bottom = glyph[r + 1][x] !== ' ';
23
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
24
+ }
25
+ });
26
+ out.push(line);
27
+ }
28
+ return out;
29
+ }
30
+ export const DEVX_LOGO = wordmark();
@@ -1,16 +1,124 @@
1
1
  import { marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import pc from 'picocolors';
4
+ import { palette } from './ui.js';
4
5
  marked.use(markedTerminal({
5
6
  width: Math.min(process.stdout.columns || 80, 100),
6
7
  reflowText: false,
8
+ showSectionPrefix: false,
7
9
  tab: 2
8
10
  }));
11
+ // marked-terminal prints the text of a list item as it was written (`code` and **bold** with their
12
+ // marks); the inline tokens of the item have to be rendered.
13
+ marked.use({
14
+ renderer: {
15
+ text(token) {
16
+ if (token && typeof token === 'object' && Array.isArray(token.tokens) && token.tokens.length > 0) {
17
+ return this.parser.parseInline(token.tokens);
18
+ }
19
+ return false;
20
+ },
21
+ },
22
+ });
23
+ /**
24
+ * Inline markdown (`code`, **bold**, *italic*) for text that arrives in pieces. The state of an open
25
+ * marker is kept between pieces, so a span that runs over several words (`**seven words in bold**`)
26
+ * is styled as a whole and the marks themselves never reach the screen. A mark that cannot be a
27
+ * mark (`2 * 3`, a lone `*`) stays as it is. Every piece is closed at its end and opened again at
28
+ * the start of the next one, so a line that is wrapped or cut in between stays well-formed.
29
+ */
30
+ export class InlineStyler {
31
+ bold = false;
32
+ italic = false;
33
+ code = false;
34
+ /** The character before the next one: whether a mark opens or closes depends on it. */
35
+ prev = ' ';
36
+ codeOn;
37
+ codeOff;
38
+ constructor() {
39
+ const [on, off] = palette.warn('\u0001').split('\u0001');
40
+ this.codeOn = on ?? '';
41
+ this.codeOff = off ?? '';
42
+ }
43
+ /** A new line: nothing stays open across a line break. */
44
+ endLine() {
45
+ this.bold = this.italic = this.code = false;
46
+ this.prev = ' ';
47
+ }
48
+ open() {
49
+ return (this.bold ? '\x1b[1m' : '') + (this.italic ? '\x1b[3m' : '') + (this.code ? this.codeOn : '');
50
+ }
51
+ close() {
52
+ return (this.code ? this.codeOff : '') + (this.italic ? '\x1b[23m' : '') + (this.bold ? '\x1b[22m' : '');
53
+ }
54
+ format(text) {
55
+ if (!text)
56
+ return '';
57
+ const colour = !!(pc.isColorSupported);
58
+ let out = colour ? this.open() : '';
59
+ const chars = Array.from(text);
60
+ const isWord = (c) => !!c && /[\p{L}\p{N}]/u.test(c);
61
+ const isSpace = (c) => c === undefined || /\s/.test(c);
62
+ for (let i = 0; i < chars.length; i++) {
63
+ const c = chars[i];
64
+ const next = chars[i + 1];
65
+ if (c === '`') {
66
+ // A code span: inside it nothing else is a mark.
67
+ this.code = !this.code;
68
+ out += colour ? (this.code ? this.codeOn : this.codeOff) : '';
69
+ this.prev = c;
70
+ continue;
71
+ }
72
+ if (this.code) {
73
+ out += c;
74
+ this.prev = c;
75
+ continue;
76
+ }
77
+ if (c === '*' && next === '*') {
78
+ const after = chars[i + 2];
79
+ const opens = !this.bold && !isSpace(after) && after !== '*' && !isWord(this.prev);
80
+ const closes = this.bold && !isSpace(this.prev);
81
+ if (opens || closes) {
82
+ this.bold = opens;
83
+ out += colour ? (opens ? '\x1b[1m' : '\x1b[22m') : '';
84
+ i++;
85
+ this.prev = '*';
86
+ continue;
87
+ }
88
+ }
89
+ else if (c === '*') {
90
+ const opens = !this.italic && !isSpace(next) && next !== '*' && !isWord(this.prev);
91
+ const closes = this.italic && !isSpace(this.prev) && !isWord(next);
92
+ if (opens || closes) {
93
+ this.italic = opens;
94
+ out += colour ? (opens ? '\x1b[3m' : '\x1b[23m') : '';
95
+ this.prev = '*';
96
+ continue;
97
+ }
98
+ }
99
+ out += c;
100
+ this.prev = c;
101
+ }
102
+ return out + (colour ? this.close() : '');
103
+ }
104
+ }
105
+ /** Width of horizontal rules / code frames: leaves room for the 2-column gutter. */
106
+ function ruleWidth(max) {
107
+ return Math.max(10, Math.min((process.stdout.columns || 40) - 4, max));
108
+ }
109
+ /** A code fence of the language `markdown` / `md` holds markdown to be shown formatted, not source to be shown as it is (`/copy` gives the source). */
110
+ const MD_FENCE_LANG = /^(?:markdown|md)$/i;
111
+ /** Takes the fences off ```markdown blocks so that their content is rendered as markdown. */
112
+ export function unfenceMarkdown(text) {
113
+ return text.replace(/^[ \t]*```[ \t]*(?:markdown|md)[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gim, (_m, body) => body.replace(/\n$/, ''));
114
+ }
9
115
  export function renderMarkdown(content) {
10
116
  if (!content)
11
117
  return '';
12
118
  try {
13
- return marked.parse(content).trim();
119
+ const text = marked.parse(unfenceMarkdown(content)).trim();
120
+ // The list marker of marked-terminal is `*`: draw a bullet instead.
121
+ return text.replace(/^((?:\s|\x1b\[[0-9;]*m)*)\* /gm, '$1• ');
14
122
  }
15
123
  catch {
16
124
  return content;
@@ -21,11 +129,23 @@ export function renderMarkdown(content) {
21
129
  * Avoids heavy synchronous AST re-parsing on every chunk, keeping token streaming buttery smooth.
22
130
  */
23
131
  export class MarkdownStreamer {
132
+ sink = (text) => {
133
+ process.stdout.write(text);
134
+ };
24
135
  buffer = '';
25
136
  inCodeBlock = false;
137
+ /** Inside a ```markdown fence: the lines are markdown, drawn formatted and without a frame. */
138
+ inMdFence = false;
26
139
  codeBlockLang = '';
27
140
  inTable = false;
28
141
  tableBuffer = [];
142
+ styler = new InlineStyler();
143
+ /** The buffer starts a line. After words of a line were written it does not: its rest is prose, not a list item or a heading. */
144
+ atLineStart = true;
145
+ /** Redirects the output (e.g. through a GutterWriter). Defaults to stdout. */
146
+ setSink(sink) {
147
+ this.sink = sink;
148
+ }
29
149
  push(chunk) {
30
150
  this.buffer += chunk;
31
151
  // 1. Process completed lines
@@ -33,13 +153,20 @@ export class MarkdownStreamer {
33
153
  const lines = this.buffer.split('\n');
34
154
  this.buffer = lines.pop() || '';
35
155
  for (const line of lines) {
36
- this.processLine(line);
156
+ if (this.atLineStart)
157
+ this.processLine(line);
158
+ else
159
+ this.sink(this.formatInline(line) + '\n');
160
+ this.atLineStart = true;
161
+ this.styler.endLine();
37
162
  }
38
163
  }
39
164
  // 2. Stream regular prose words immediately in real-time
40
165
  if (!this.inCodeBlock && !this.inTable && this.buffer.length > 0) {
41
166
  const trimmed = this.buffer.trimStart();
42
- const isSpecialLine = trimmed.startsWith('#') ||
167
+ // Only spaces so far (the indent of a nested item): wait for what follows.
168
+ const onlyBlank = trimmed.length === 0;
169
+ const isSpecialLine = onlyBlank || this.atLineStart && (trimmed.startsWith('#') ||
43
170
  trimmed.startsWith('|') ||
44
171
  trimmed.startsWith('```') ||
45
172
  trimmed.startsWith('- [') ||
@@ -51,13 +178,15 @@ export class MarkdownStreamer {
51
178
  /^\d+\.\s/.test(trimmed) ||
52
179
  trimmed.startsWith('> ') ||
53
180
  trimmed === '---' ||
54
- trimmed === '***';
181
+ trimmed === '***');
55
182
  if (!isSpecialLine) {
56
183
  const lastSpaceIdx = Math.max(this.buffer.lastIndexOf(' '), this.buffer.lastIndexOf('\t'));
57
- if (lastSpaceIdx > 0) {
184
+ // At the start of a line, a first word that is not finished yet (` *`, the marker of an indented item) waits.
185
+ if (lastSpaceIdx > 0 && (!this.atLineStart || /\S\s/.test(this.buffer))) {
58
186
  const wordsToFlush = this.buffer.slice(0, lastSpaceIdx + 1);
59
187
  this.buffer = this.buffer.slice(lastSpaceIdx + 1);
60
- process.stdout.write(this.formatInline(wordsToFlush));
188
+ this.sink(this.formatInline(wordsToFlush));
189
+ this.atLineStart = false;
61
190
  }
62
191
  }
63
192
  }
@@ -66,23 +195,31 @@ export class MarkdownStreamer {
66
195
  const trimmed = line.trim();
67
196
  // 1. Code block boundary
68
197
  if (trimmed.startsWith('```')) {
198
+ if (this.inMdFence) {
199
+ this.inMdFence = false;
200
+ return;
201
+ }
202
+ if (!this.inCodeBlock && MD_FENCE_LANG.test(trimmed.slice(3).trim().split(/\s+/)[0] ?? '')) {
203
+ this.inMdFence = true;
204
+ return;
205
+ }
69
206
  if (this.inCodeBlock) {
70
207
  this.inCodeBlock = false;
71
208
  this.codeBlockLang = '';
72
- process.stdout.write(pc.dim('└' + '─'.repeat(Math.min(process.stdout.columns || 40, 50))) + '\n');
209
+ this.sink(palette.rule('└' + '─'.repeat(ruleWidth(50))) + '\n');
73
210
  return;
74
211
  }
75
212
  else {
76
213
  this.inCodeBlock = true;
77
214
  this.codeBlockLang = trimmed.slice(3).trim();
78
215
  const langTag = this.codeBlockLang ? ` [${this.codeBlockLang}] ` : ' ';
79
- process.stdout.write('\n' + pc.cyan('┌─' + langTag + '─'.repeat(Math.max(2, Math.min(process.stdout.columns || 40, 50) - langTag.length - 2))) + '\n');
216
+ this.sink('\n' + palette.accent('┌─' + langTag + '─'.repeat(Math.max(2, ruleWidth(50) - langTag.length - 2))) + '\n');
80
217
  return;
81
218
  }
82
219
  }
83
220
  // 2. Inside code block: fast syntax-tinted output
84
221
  if (this.inCodeBlock) {
85
- process.stdout.write(pc.green('│ ') + pc.white(line) + '\n');
222
+ this.sink(palette.rule('│ ') + palette.text(line) + '\n');
86
223
  return;
87
224
  }
88
225
  // 3. Tables
@@ -101,73 +238,68 @@ export class MarkdownStreamer {
101
238
  const mark = todoMatch[2].toLowerCase();
102
239
  const taskText = this.formatInline(todoMatch[3]);
103
240
  if (mark === 'x' || mark === '✓') {
104
- process.stdout.write(' ' + pc.green('[✓] ') + pc.dim(taskText) + '\n');
241
+ this.sink(' ' + palette.ok('[✓] ') + palette.faint(taskText) + '\n');
105
242
  }
106
243
  else if (mark === '/' || mark === '>') {
107
- process.stdout.write(' ' + pc.bold(pc.green('[ ] ' + taskText)) + '\n');
244
+ this.sink(' ' + palette.bold(palette.ok('[ ] ' + taskText)) + '\n');
108
245
  }
109
246
  else {
110
- process.stdout.write(' ' + pc.dim('[ ] ') + pc.white(taskText) + '\n');
247
+ this.sink(' ' + palette.faint('[ ] ') + palette.text(taskText) + '\n');
111
248
  }
112
249
  return;
113
250
  }
114
251
  // 5. OpenCode Action arrows (→ Read ..., → Edit ...)
115
252
  if (trimmed.startsWith('→ ')) {
116
- process.stdout.write(pc.bold(pc.cyan('→ ')) + pc.white(this.formatInline(trimmed.slice(2))) + '\n');
253
+ this.sink(palette.bold(palette.accent('→ ')) + palette.text(this.formatInline(trimmed.slice(2))) + '\n');
117
254
  return;
118
255
  }
119
256
  // 6. Regular line fast formatting (Headers, Bullets, Bold, Dims)
257
+ const plainMarks = (text) => text.replace(/\*{1,2}([^*\n]+)\*{1,2}/g, '$1').replace(/`/g, '');
120
258
  if (trimmed.startsWith('# ')) {
121
- process.stdout.write('\n' + pc.bold(pc.cyan(trimmed.slice(2))) + '\n\n');
259
+ this.sink('\n' + palette.bold(palette.accent(plainMarks(trimmed.slice(2)))) + '\n\n');
122
260
  }
123
261
  else if (trimmed.startsWith('## ')) {
124
- process.stdout.write('\n' + pc.bold(pc.white(trimmed.slice(3))) + '\n');
262
+ this.sink('\n' + palette.bold(palette.text(plainMarks(trimmed.slice(3)))) + '\n');
125
263
  }
126
- else if (trimmed.startsWith('### ')) {
127
- process.stdout.write('\n' + pc.bold(pc.yellow(trimmed.slice(4))) + '\n');
264
+ else if (/^#{3,6} /.test(trimmed)) {
265
+ this.sink('\n' + palette.bold(palette.warn(plainMarks(trimmed.replace(/^#+ /, '')))) + '\n');
128
266
  }
129
267
  else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
130
- process.stdout.write(' ' + pc.cyan('• ') + this.formatInline(line.slice(2)) + '\n');
268
+ // A nested item keeps its indentation.
269
+ const indent = ' '.repeat(Math.min(8, line.length - line.trimStart().length));
270
+ this.sink(' ' + indent + palette.accent('• ') + this.formatInline(trimmed.slice(2)) + '\n');
131
271
  }
132
272
  else if (/^\d+\.\s/.test(trimmed)) {
133
273
  const match = trimmed.match(/^(\d+\.)\s*(.*)$/);
134
274
  if (match) {
135
- process.stdout.write(' ' + pc.cyan(match[1]) + ' ' + this.formatInline(match[2]) + '\n');
275
+ this.sink(' ' + palette.accent(match[1]) + ' ' + this.formatInline(match[2]) + '\n');
136
276
  }
137
277
  else {
138
- process.stdout.write(this.formatInline(line) + '\n');
278
+ this.sink(this.formatInline(line) + '\n');
139
279
  }
140
280
  }
141
281
  else if (trimmed.startsWith('> ')) {
142
- process.stdout.write(pc.dim(' │ ' + trimmed.slice(2)) + '\n');
282
+ this.sink(palette.faint(' │ ') + this.formatInline(trimmed.slice(2)) + '\n');
143
283
  }
144
284
  else if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
145
- process.stdout.write(pc.dim('─'.repeat(Math.min(process.stdout.columns || 40, 60))) + '\n');
285
+ this.sink(palette.rule('─'.repeat(ruleWidth(60))) + '\n');
146
286
  }
147
287
  else {
148
- process.stdout.write(this.formatInline(line) + '\n');
288
+ this.sink(this.formatInline(line) + '\n');
149
289
  }
150
290
  }
151
291
  formatInline(text) {
152
- if (!text)
153
- return '';
154
- // Format `code`
155
- let formatted = text.replace(/`([^`]+)`/g, (_, c) => pc.yellow(c));
156
- // Format **bold**
157
- formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, b) => pc.bold(b));
158
- // Format *italic*
159
- formatted = formatted.replace(/\*([^*]+)\*/g, (_, i) => pc.italic(i));
160
- return formatted;
292
+ return this.styler.format(text);
161
293
  }
162
294
  flushTable() {
163
295
  if (this.tableBuffer.length > 0) {
164
296
  const tableMd = this.tableBuffer.join('\n');
165
297
  try {
166
298
  const rendered = renderMarkdown(tableMd);
167
- process.stdout.write(rendered + '\n');
299
+ this.sink(rendered + '\n');
168
300
  }
169
301
  catch {
170
- process.stdout.write(tableMd + '\n');
302
+ this.sink(tableMd + '\n');
171
303
  }
172
304
  this.tableBuffer = [];
173
305
  }
@@ -175,9 +307,12 @@ export class MarkdownStreamer {
175
307
  reset() {
176
308
  this.buffer = '';
177
309
  this.inCodeBlock = false;
310
+ this.inMdFence = false;
178
311
  this.codeBlockLang = '';
179
312
  this.inTable = false;
180
313
  this.tableBuffer = [];
314
+ this.atLineStart = true;
315
+ this.styler.endLine();
181
316
  }
182
317
  finish() {
183
318
  if (this.buffer) {
@@ -190,14 +325,16 @@ export class MarkdownStreamer {
190
325
  this.flushTable();
191
326
  if (this.buffer) {
192
327
  if (this.inCodeBlock) {
193
- process.stdout.write(pc.green('│ ') + pc.white(this.buffer) + '\n');
194
- process.stdout.write(pc.dim('└' + '─'.repeat(Math.min(process.stdout.columns || 40, 50))) + '\n');
328
+ this.sink(palette.rule('│ ') + palette.text(this.buffer) + '\n');
329
+ this.sink(palette.rule('└' + '─'.repeat(ruleWidth(50))) + '\n');
195
330
  this.inCodeBlock = false;
196
331
  }
197
332
  else {
198
- process.stdout.write(this.formatInline(this.buffer) + '\n');
333
+ this.sink(this.formatInline(this.buffer) + '\n');
199
334
  }
200
335
  this.buffer = '';
336
+ this.atLineStart = true;
337
+ this.styler.endLine();
201
338
  }
202
339
  }
203
340
  }