phewsh 0.15.9 → 0.15.10

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.
@@ -37,7 +37,7 @@ async function askForInput() {
37
37
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
38
  return new Promise((resolve) => {
39
39
  console.log('\n Describe what you\'re building. Be as messy as you want.\n');
40
- console.log(' (You\'re the David Rose. PHEWSH is the business registration clerk.)\n');
40
+ console.log(' (You bring the messy idea. PHEWSH compiles it into a clear, structured spec.)\n');
41
41
  process.stdout.write(' > ');
42
42
  let input = '';
43
43
  rl.on('line', (line) => { input += (input ? ' ' : '') + line.trim(); });
@@ -244,6 +244,13 @@ async function streamChat(apiKey, messages, systemPrompt, modelId, opts = {}) {
244
244
  let completionTokens = null;
245
245
  let firstToken = true;
246
246
 
247
+ // Line-buffered markdown render at a TTY; raw passthrough otherwise. The
248
+ // spinner stops on first rendered output, not first raw token.
249
+ const stopSpin = () => { if (firstToken) { spin.stop(); firstToken = false; } };
250
+ const render = process.stdout.isTTY
251
+ ? require('../lib/md').streamRenderer((out) => { stopSpin(); process.stdout.write(out); })
252
+ : null;
253
+
247
254
  for await (const chunk of response.body) {
248
255
  const text = Buffer.from(chunk).toString('utf-8');
249
256
  const lines = text.split('\n').filter(l => l.startsWith('data: '));
@@ -253,11 +260,8 @@ async function streamChat(apiKey, messages, systemPrompt, modelId, opts = {}) {
253
260
  try {
254
261
  const parsed = JSON.parse(data);
255
262
  if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
256
- if (firstToken) {
257
- spin.stop();
258
- firstToken = false;
259
- }
260
- process.stdout.write(parsed.delta.text);
263
+ if (render) render.push(parsed.delta.text);
264
+ else { stopSpin(); process.stdout.write(parsed.delta.text); }
261
265
  fullResponse += parsed.delta.text;
262
266
  }
263
267
  if (parsed.type === 'message_start' && parsed.message?.usage) {
@@ -270,6 +274,7 @@ async function streamChat(apiKey, messages, systemPrompt, modelId, opts = {}) {
270
274
  }
271
275
  }
272
276
 
277
+ if (render) render.flush();
273
278
  if (firstToken) spin.stop();
274
279
  process.stdout.write('\n');
275
280
 
package/lib/harnesses.js CHANGED
@@ -106,20 +106,30 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
106
106
  child.stdin.end();
107
107
 
108
108
  let stderr = '';
109
- let assembled = ''; // text shown to the user and returned
109
+ let assembled = ''; // raw text returned + kept in history (never ANSI)
110
110
  let resultFallback = ''; // claude's final `result` field — never-blank guard
111
111
  let jsonBuf = '';
112
112
  let firstByte = false;
113
113
 
114
+ // Spinner stop + leading newline fire on first *displayed* output, so the
115
+ // line-buffered renderer never leaves a spinner-stopped-but-blank gap.
116
+ function onFirstShow() {
117
+ if (firstByte) return;
118
+ firstByte = true;
119
+ if (spin) { spin.stop(); spin = null; }
120
+ if (show) process.stdout.write('\n');
121
+ }
122
+ // Render markdown only at a real TTY; pipes/quiet get raw passthrough so
123
+ // council parsing and scripted use stay clean (no stray ANSI).
124
+ const render = (show && process.stdout.isTTY)
125
+ ? require('./md').streamRenderer((out) => { onFirstShow(); process.stdout.write(out); })
126
+ : null;
127
+
114
128
  function emit(text) {
115
129
  if (!text) return;
116
- if (!firstByte) {
117
- firstByte = true;
118
- if (spin) { spin.stop(); spin = null; }
119
- if (show) process.stdout.write('\n');
120
- }
121
- assembled += text;
122
- if (show) process.stdout.write(text);
130
+ assembled += text; // always raw
131
+ if (render) render.push(text);
132
+ else if (show) { onFirstShow(); process.stdout.write(text); }
123
133
  }
124
134
 
125
135
  child.stdout.on('data', (d) => {
@@ -157,11 +167,13 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
157
167
  if (obj.type === 'result' && typeof obj.result === 'string') resultFallback = obj.result;
158
168
  } catch { /* partial trailing line */ }
159
169
  }
170
+ if (render) render.flush(); // render any trailing partial line
160
171
  // Streaming produced nothing but the result has text → show it. Never blank.
161
172
  let finalText = assembled;
162
173
  if (!assembled.trim() && resultFallback) {
163
174
  finalText = resultFallback;
164
- if (show) process.stdout.write('\n' + resultFallback);
175
+ if (render) { render.push(resultFallback); render.flush(); }
176
+ else if (show) { onFirstShow(); process.stdout.write(resultFallback); }
165
177
  }
166
178
  if (show) process.stdout.write('\n');
167
179
  if (child._phewshCancelled) return reject(new Error(`${h.label} cancelled`));
package/lib/md.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // Terminal markdown rendering, safe for line-buffered streaming.
4
+ //
5
+ // Each completed line is rendered on its own — no cursor movement, no
6
+ // in-place rewrites. That is deliberate: mid-stream cursor tricks under line
7
+ // wrapping are the exact Apple Terminal hazard that has bitten this project
8
+ // before. We hold tokens until a newline, render that finished line, and print
9
+ // it. The only block-level state carried across lines is the code-fence flag,
10
+ // so fenced code renders literally instead of as inline markdown.
11
+
12
+ const A = {
13
+ reset: '\x1b[0m',
14
+ bold: '\x1b[1m',
15
+ dim: '\x1b[2m',
16
+ ital: '\x1b[3m', italOff: '\x1b[23m',
17
+ uline: '\x1b[4m',
18
+ cream: '\x1b[38;5;230m', // bold emphasis
19
+ teal: '\x1b[38;5;79m', // code, links, headers
20
+ sage: '\x1b[38;5;151m', // list items
21
+ slate: '\x1b[38;5;247m', // dividers, quotes, link urls
22
+ };
23
+
24
+ // Inline spans: bold, italic, code, links. `base` is the SGR sequence to
25
+ // restore the line's colour after each span's reset, so the rest of the line
26
+ // keeps its block colour. Pass '' for default-foreground body text.
27
+ function inlineMd(t, base = '') {
28
+ return t
29
+ .replace(/\*\*([^*]+)\*\*/g, (_, x) => `${A.bold}${A.cream}${x}${A.reset}${base}`)
30
+ .replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, (_, p, x) => `${p}${A.ital}${x}${A.italOff}${base}`)
31
+ .replace(/`([^`]+)`/g, (_, x) => `${A.teal}${x}${A.reset}${base}`)
32
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g,
33
+ (_, x, u) => `${A.uline}${A.teal}${x}${A.reset}${base} ${A.dim}${A.slate}(${u})${A.reset}${base}`);
34
+ }
35
+
36
+ // Render one complete line, mutating `state` for block-level context.
37
+ // Body text keeps the default terminal foreground (bright) — only structural
38
+ // elements get colour, so a reply reads as a reply, not as dimmed UI chrome.
39
+ function renderLine(line, state = {}) {
40
+ const fence = line.match(/^\s*```+\s*\w*\s*$/);
41
+ if (fence) {
42
+ state.inFence = !state.inFence;
43
+ return ` ${A.slate}${A.dim}┄┄┄${A.reset}`;
44
+ }
45
+ if (state.inFence) {
46
+ return ` ${A.teal}${line}${A.reset}`; // literal code, no inline md
47
+ }
48
+ if (/^#{1,2}\s/.test(line)) return `\n${A.bold}${A.teal}${inlineMd(line.replace(/^#+\s*/, ''), A.teal)}${A.reset}`;
49
+ if (/^#{3,}\s/.test(line)) return `${A.cream}${inlineMd(line.replace(/^#+\s*/, ''), A.cream)}${A.reset}`;
50
+ if (/^\s*[-*]\s/.test(line)) return ` ${A.teal}·${A.reset} ${A.sage}${inlineMd(line.replace(/^\s*[-*]\s*/, ''), A.sage)}${A.reset}`;
51
+ if (/^\s*\d+\.\s/.test(line)) return ` ${A.sage}${inlineMd(line.trim(), A.sage)}${A.reset}`;
52
+ if (/^\s*>\s?/.test(line)) return ` ${A.slate}${A.ital}${inlineMd(line.replace(/^\s*>\s?/, ''), A.slate)}${A.italOff}${A.reset}`;
53
+ if (/^---+\s*$/.test(line)) return ` ${A.slate}${'─'.repeat(40)}${A.reset}`;
54
+ if (line.trim() === '') return '';
55
+ return inlineMd(line, ''); // body: default foreground, inline styling only
56
+ }
57
+
58
+ // Line-buffered streaming renderer. Feed raw token text via push(); each time a
59
+ // newline completes a line, the rendered line (with its trailing '\n') is handed
60
+ // to write(). flush() renders any trailing partial line at stream end. The
61
+ // caller's write() is where first-output side effects (stop the spinner, print a
62
+ // leading newline) belong, so they fire on first *rendered* output, not on the
63
+ // first raw token — no spinner-stopped-but-blank gap.
64
+ function streamRenderer(write) {
65
+ let buf = '';
66
+ const state = { inFence: false };
67
+ return {
68
+ push(text) {
69
+ buf += text;
70
+ let nl;
71
+ while ((nl = buf.indexOf('\n')) !== -1) {
72
+ const line = buf.slice(0, nl);
73
+ buf = buf.slice(nl + 1);
74
+ write(renderLine(line, state) + '\n');
75
+ }
76
+ },
77
+ flush() {
78
+ if (buf.length) {
79
+ write(renderLine(buf, state) + '\n');
80
+ buf = '';
81
+ }
82
+ },
83
+ };
84
+ }
85
+
86
+ module.exports = { inlineMd, renderLine, streamRenderer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.9",
3
+ "version": "0.15.10",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"