phewsh 0.15.7 → 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.
- package/commands/clarify.js +1 -1
- package/commands/session.js +14 -6
- package/lib/harnesses.js +85 -9
- package/lib/md.js +86 -0
- package/package.json +1 -1
package/commands/clarify.js
CHANGED
|
@@ -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
|
|
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(); });
|
package/commands/session.js
CHANGED
|
@@ -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 (
|
|
257
|
-
|
|
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
|
|
|
@@ -331,9 +336,12 @@ async function main() {
|
|
|
331
336
|
await ui.brandReveal();
|
|
332
337
|
|
|
333
338
|
if (config?.apiKey && !config.apiKey.startsWith('sk-')) {
|
|
334
|
-
|
|
339
|
+
// Persist the cleanup so this never nags again — an unusable key
|
|
340
|
+
// (not sk-…) helps nobody, and harness routes need no key at all.
|
|
341
|
+
console.log(` ${ember('!')} ${sage('Cleared an unusable stored API key.')} ${slate('harness routes need none — /key to add one')}`);
|
|
335
342
|
console.log('');
|
|
336
343
|
config.apiKey = null;
|
|
344
|
+
try { saveConfig(config); } catch { /* read-only home — in-memory clear still holds */ }
|
|
337
345
|
route = resolveRoute(config, harnesses);
|
|
338
346
|
}
|
|
339
347
|
|
package/lib/harnesses.js
CHANGED
|
@@ -20,7 +20,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
20
20
|
// list of its own, so it can never go stale. Harnesses without a known
|
|
21
21
|
// model flag ignore the preference and use their own config.
|
|
22
22
|
const HARNESSES = {
|
|
23
|
-
'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, modelHints: ['sonnet', 'opus', 'haiku', '
|
|
23
|
+
'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, modelHints: ['sonnet', 'opus', 'haiku'], streamFormat: 'claude-json', args: (p, m) => ['-p', p, '--output-format', 'stream-json', '--include-partial-messages', '--verbose', ...(m ? ['--model', m] : [])] },
|
|
24
24
|
'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', models: true, args: (p, m) => ['exec', '--skip-git-repo-check', ...(m ? ['-m', m] : []), p] },
|
|
25
25
|
'gemini': { bin: 'gemini', label: 'Gemini CLI', role: "another model's take", auth: 'Google login', models: true, args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
|
|
26
26
|
'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', role: 'edits files', auth: 'Cursor account', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
|
|
@@ -87,6 +87,17 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
|
|
|
87
87
|
const prompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${userPrompt}` : userPrompt;
|
|
88
88
|
const model = h.models ? opts.model : undefined;
|
|
89
89
|
|
|
90
|
+
// Spinner during the wait, and live token streaming where the harness
|
|
91
|
+
// supports it — no path is ever a dead blank screen. quiet (council) parses
|
|
92
|
+
// silently so parallel runs don't interleave into soup.
|
|
93
|
+
// Output is written whenever this isn't a quiet (council) run. The spinner
|
|
94
|
+
// additionally needs a TTY — piped/test contexts get the text, no spinner.
|
|
95
|
+
const show = !opts.quiet;
|
|
96
|
+
let spin = null;
|
|
97
|
+
if (show && process.stdout.isTTY) {
|
|
98
|
+
try { spin = require('./ui').spinner('thinking'); } catch { spin = null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
return new Promise((resolve, reject) => {
|
|
91
102
|
const child = spawn(h.bin, h.args(prompt, model), { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
92
103
|
ACTIVE_CHILDREN.add(child);
|
|
@@ -94,20 +105,85 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
|
|
|
94
105
|
// Some harnesses (codex exec, gemini) wait for stdin EOF before running.
|
|
95
106
|
child.stdin.end();
|
|
96
107
|
|
|
97
|
-
let stdout = '';
|
|
98
108
|
let stderr = '';
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
let assembled = ''; // raw text — returned + kept in history (never ANSI)
|
|
110
|
+
let resultFallback = ''; // claude's final `result` field — never-blank guard
|
|
111
|
+
let jsonBuf = '';
|
|
112
|
+
let firstByte = false;
|
|
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
|
+
|
|
128
|
+
function emit(text) {
|
|
129
|
+
if (!text) return;
|
|
130
|
+
assembled += text; // always raw
|
|
131
|
+
if (render) render.push(text);
|
|
132
|
+
else if (show) { onFirstShow(); process.stdout.write(text); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
child.stdout.on('data', (d) => {
|
|
136
|
+
if (h.streamFormat === 'claude-json') {
|
|
137
|
+
// Newline-delimited JSON: each line is one event. text_delta events
|
|
138
|
+
// carry the live tokens; the final `result` is the fallback.
|
|
139
|
+
jsonBuf += d.toString();
|
|
140
|
+
let nl;
|
|
141
|
+
while ((nl = jsonBuf.indexOf('\n')) !== -1) {
|
|
142
|
+
const line = jsonBuf.slice(0, nl).trim();
|
|
143
|
+
jsonBuf = jsonBuf.slice(nl + 1);
|
|
144
|
+
if (!line) continue;
|
|
145
|
+
let obj;
|
|
146
|
+
// A complete line that isn't JSON is real output (plain-text error,
|
|
147
|
+
// or a non-streaming stub) — show it rather than swallow it.
|
|
148
|
+
try { obj = JSON.parse(line); } catch { emit(line + '\n'); continue; }
|
|
149
|
+
if (obj.type === 'stream_event'
|
|
150
|
+
&& obj.event?.type === 'content_block_delta'
|
|
151
|
+
&& obj.event.delta?.type === 'text_delta') {
|
|
152
|
+
emit(obj.event.delta.text);
|
|
153
|
+
} else if (obj.type === 'result' && typeof obj.result === 'string') {
|
|
154
|
+
resultFallback = obj.result;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
emit(d.toString());
|
|
159
|
+
}
|
|
160
|
+
});
|
|
103
161
|
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
104
162
|
child.on('close', (code) => {
|
|
105
|
-
if (
|
|
163
|
+
if (spin) { spin.stop(); spin = null; }
|
|
164
|
+
if (h.streamFormat === 'claude-json' && jsonBuf.trim()) {
|
|
165
|
+
try {
|
|
166
|
+
const obj = JSON.parse(jsonBuf.trim());
|
|
167
|
+
if (obj.type === 'result' && typeof obj.result === 'string') resultFallback = obj.result;
|
|
168
|
+
} catch { /* partial trailing line */ }
|
|
169
|
+
}
|
|
170
|
+
if (render) render.flush(); // render any trailing partial line
|
|
171
|
+
// Streaming produced nothing but the result has text → show it. Never blank.
|
|
172
|
+
let finalText = assembled;
|
|
173
|
+
if (!assembled.trim() && resultFallback) {
|
|
174
|
+
finalText = resultFallback;
|
|
175
|
+
if (render) { render.push(resultFallback); render.flush(); }
|
|
176
|
+
else if (show) { onFirstShow(); process.stdout.write(resultFallback); }
|
|
177
|
+
}
|
|
178
|
+
if (show) process.stdout.write('\n');
|
|
106
179
|
if (child._phewshCancelled) return reject(new Error(`${h.label} cancelled`));
|
|
107
|
-
if (code === 0) resolve(
|
|
180
|
+
if (code === 0) resolve(finalText);
|
|
108
181
|
else reject(new Error(`${h.label} exited ${code}${stderr ? `\n ${stderr.trim().split('\n').slice(-3).join('\n ')}` : ''}`));
|
|
109
182
|
});
|
|
110
|
-
child.on('error', (e) =>
|
|
183
|
+
child.on('error', (e) => {
|
|
184
|
+
if (spin) { spin.stop(); spin = null; }
|
|
185
|
+
reject(new Error(`Could not run ${h.bin}: ${e.message}`));
|
|
186
|
+
});
|
|
111
187
|
});
|
|
112
188
|
}
|
|
113
189
|
|
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 };
|