@aerode/pish 0.8.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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/agent.js +398 -0
- package/dist/app.js +473 -0
- package/dist/config.js +187 -0
- package/dist/hooks.js +198 -0
- package/dist/log.js +49 -0
- package/dist/main.js +89 -0
- package/dist/osc.js +157 -0
- package/dist/recorder.js +177 -0
- package/dist/render.js +455 -0
- package/dist/strip.js +46 -0
- package/dist/theme.js +64 -0
- package/dist/vterm.js +59 -0
- package/package.json +49 -0
- package/postinstall.sh +7 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer — UI output for pish.
|
|
3
|
+
*
|
|
4
|
+
* Uses pi-tui components (Box, Text, Markdown) for block rendering.
|
|
5
|
+
* All output goes to process.stderr.
|
|
6
|
+
*/
|
|
7
|
+
import { Box, Markdown, Text, visibleWidth, } from '@mariozechner/pi-tui';
|
|
8
|
+
import { bold, createMarkdownTheme, dim, TAG, theme, toolBg } from './theme.js';
|
|
9
|
+
// ─── Banner / Exit / Control ───
|
|
10
|
+
export function printBanner(cfg) {
|
|
11
|
+
if (cfg.noBanner)
|
|
12
|
+
return;
|
|
13
|
+
const parts = [`v${cfg.version}`, dim(cfg.shell)];
|
|
14
|
+
if (cfg.noAgent)
|
|
15
|
+
parts.push(theme.warning('no-agent'));
|
|
16
|
+
process.stderr.write(`${TAG} ${parts.join(dim(' │ '))}\n`);
|
|
17
|
+
}
|
|
18
|
+
export function printExit() {
|
|
19
|
+
process.stderr.write(`${TAG} ${dim('session ended')}\n`);
|
|
20
|
+
}
|
|
21
|
+
export function printControl(cmd) {
|
|
22
|
+
process.stderr.write(`${TAG} ${dim(cmd)}\n`);
|
|
23
|
+
}
|
|
24
|
+
/** Render control command result (success or error). */
|
|
25
|
+
export function printControlResult(cmd, response) {
|
|
26
|
+
const name = cmd.trim().split(/\s+/)[0];
|
|
27
|
+
if (!response.success) {
|
|
28
|
+
const err = response.error ?? 'unknown error';
|
|
29
|
+
process.stderr.write(`${TAG} ${theme.error('✗')} ${dim(name)}: ${err}\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const d = response.data;
|
|
33
|
+
switch (response.command) {
|
|
34
|
+
case 'set_model': {
|
|
35
|
+
const prov = d?.provider;
|
|
36
|
+
const provider = typeof prov === 'object' && prov !== null
|
|
37
|
+
? (prov.id ?? '')
|
|
38
|
+
: String(prov ?? '');
|
|
39
|
+
const modelId = d?.id ?? d?.modelId ?? '';
|
|
40
|
+
process.stderr.write(`${TAG} ${theme.success('✓')} model → ${provider ? `${provider} ` : ''}${theme.accent(modelId)}\n`);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case 'set_thinking_level': {
|
|
44
|
+
const arg = cmd.trim().split(/\s+/).slice(1).join(' ') || 'medium';
|
|
45
|
+
process.stderr.write(`${TAG} ${theme.success('✓')} thinking → ${theme.accent(arg)}\n`);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'compact': {
|
|
49
|
+
if (d?.tokensBefore) {
|
|
50
|
+
process.stderr.write(`${TAG} ${theme.success('✓')} compacted ${formatCompact(d.tokensBefore)} tokens\n`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
process.stderr.write(`${TAG} ${theme.success('✓')} compacted\n`);
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
default:
|
|
58
|
+
process.stderr.write(`${TAG} ${theme.success('✓')} ${dim(name)}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function printNotice(msg) {
|
|
62
|
+
process.stderr.write(`${TAG} ${theme.warning('⚠')} ${msg}\n`);
|
|
63
|
+
}
|
|
64
|
+
// ─── Spinner ───
|
|
65
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
66
|
+
export function startSpinner(label) {
|
|
67
|
+
let frame = 0;
|
|
68
|
+
let stopped = false;
|
|
69
|
+
const maxLabelCols = termWidth() - 3;
|
|
70
|
+
let truncLabel = label;
|
|
71
|
+
if (visibleWidth(label) > maxLabelCols) {
|
|
72
|
+
while (visibleWidth(truncLabel) > maxLabelCols - 3 &&
|
|
73
|
+
truncLabel.length > 0) {
|
|
74
|
+
truncLabel = truncLabel.slice(0, -1);
|
|
75
|
+
}
|
|
76
|
+
truncLabel += '...';
|
|
77
|
+
}
|
|
78
|
+
const render = () => {
|
|
79
|
+
const f = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
80
|
+
process.stderr.write(`\r${theme.accent(f)} ${theme.muted(truncLabel)}`);
|
|
81
|
+
frame++;
|
|
82
|
+
};
|
|
83
|
+
render();
|
|
84
|
+
const timer = setInterval(render, 80);
|
|
85
|
+
return () => {
|
|
86
|
+
if (stopped)
|
|
87
|
+
return;
|
|
88
|
+
stopped = true;
|
|
89
|
+
clearInterval(timer);
|
|
90
|
+
process.stderr.write('\r\x1b[2K');
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ─── Helpers ───
|
|
94
|
+
function termWidth() {
|
|
95
|
+
return process.stderr.columns || 80;
|
|
96
|
+
}
|
|
97
|
+
function flush(comp) {
|
|
98
|
+
for (const line of comp.render(termWidth())) {
|
|
99
|
+
process.stderr.write(`${line}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function shortenPath(p) {
|
|
103
|
+
const home = process.env.HOME;
|
|
104
|
+
if (home && p.startsWith(home))
|
|
105
|
+
return `~${p.slice(home.length)}`;
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
108
|
+
function truncate(s, max) {
|
|
109
|
+
const flat = s.replace(/\n/g, ' ');
|
|
110
|
+
if (flat.length <= max)
|
|
111
|
+
return flat;
|
|
112
|
+
return `${flat.slice(0, max - 3)}...`;
|
|
113
|
+
}
|
|
114
|
+
// ─── Tool title (matching pi's per-tool renderCall) ───
|
|
115
|
+
function formatToolTitle(toolName, args) {
|
|
116
|
+
if (toolName === 'bash' && typeof args.command === 'string') {
|
|
117
|
+
return `$ ${args.command}`;
|
|
118
|
+
}
|
|
119
|
+
if (toolName === 'read' && typeof args.path === 'string') {
|
|
120
|
+
const p = shortenPath(args.path);
|
|
121
|
+
const offset = args.offset;
|
|
122
|
+
const limit = args.limit;
|
|
123
|
+
let display = `read ${theme.accent(p)}`;
|
|
124
|
+
if (offset !== undefined || limit !== undefined) {
|
|
125
|
+
const start = offset ?? 1;
|
|
126
|
+
const end = limit !== undefined ? start + limit - 1 : '';
|
|
127
|
+
display += theme.warning(`:${start}${end ? `-${end}` : ''}`);
|
|
128
|
+
}
|
|
129
|
+
return display;
|
|
130
|
+
}
|
|
131
|
+
if (toolName === 'write' && typeof args.path === 'string') {
|
|
132
|
+
return `write ${theme.accent(shortenPath(args.path))}`;
|
|
133
|
+
}
|
|
134
|
+
if (toolName === 'edit' && typeof args.path === 'string') {
|
|
135
|
+
return `edit ${theme.accent(shortenPath(args.path))}`;
|
|
136
|
+
}
|
|
137
|
+
const keys = Object.keys(args);
|
|
138
|
+
if (keys.length > 0)
|
|
139
|
+
return `${toolName} ${truncate(String(args[keys[0]]), 60)}`;
|
|
140
|
+
return toolName;
|
|
141
|
+
}
|
|
142
|
+
function extractResultText(result) {
|
|
143
|
+
if (!result || typeof result !== 'object')
|
|
144
|
+
return '';
|
|
145
|
+
const r = result;
|
|
146
|
+
if (Array.isArray(r.content)) {
|
|
147
|
+
const texts = [];
|
|
148
|
+
for (const item of r.content) {
|
|
149
|
+
if (item && typeof item === 'object') {
|
|
150
|
+
const rec = item;
|
|
151
|
+
if (rec.type === 'text' && typeof rec.text === 'string') {
|
|
152
|
+
texts.push(rec.text);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return texts.join('\n').trim();
|
|
157
|
+
}
|
|
158
|
+
if (typeof r.text === 'string')
|
|
159
|
+
return r.text;
|
|
160
|
+
if (typeof r.output === 'string')
|
|
161
|
+
return r.output;
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
// ─── Status bar (pi footer style) ───
|
|
165
|
+
function formatCompact(n) {
|
|
166
|
+
if (n < 1000)
|
|
167
|
+
return n.toString();
|
|
168
|
+
if (n < 10000)
|
|
169
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
170
|
+
if (n < 1000000)
|
|
171
|
+
return `${Math.round(n / 1000)}k`;
|
|
172
|
+
if (n < 10000000)
|
|
173
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
174
|
+
return `${Math.round(n / 1000000)}M`;
|
|
175
|
+
}
|
|
176
|
+
function renderStatusBar(durationMs, usage) {
|
|
177
|
+
const w = termWidth();
|
|
178
|
+
const t = (durationMs / 1000).toFixed(1);
|
|
179
|
+
if (!usage || usage.totalTokens === 0) {
|
|
180
|
+
process.stderr.write(`${theme.success('✓')} done (${t}s)\n`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const parts = [];
|
|
184
|
+
if (usage.inputTokens)
|
|
185
|
+
parts.push(`↑${formatCompact(usage.inputTokens)}`);
|
|
186
|
+
if (usage.outputTokens)
|
|
187
|
+
parts.push(`↓${formatCompact(usage.outputTokens)}`);
|
|
188
|
+
if (usage.cacheRead)
|
|
189
|
+
parts.push(`R${formatCompact(usage.cacheRead)}`);
|
|
190
|
+
if (usage.cacheWrite)
|
|
191
|
+
parts.push(`W${formatCompact(usage.cacheWrite)}`);
|
|
192
|
+
if (usage.cost > 0)
|
|
193
|
+
parts.push(`$${usage.cost.toFixed(3)}`);
|
|
194
|
+
if (usage.contextWindow > 0) {
|
|
195
|
+
const windowStr = formatCompact(usage.contextWindow);
|
|
196
|
+
if (usage.contextPercent !== null) {
|
|
197
|
+
parts.push(`${usage.contextPercent.toFixed(1)}%/${windowStr}`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
parts.push(`/${windowStr}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
parts.push(`${t}s`);
|
|
204
|
+
const leftContent = parts.join(' ');
|
|
205
|
+
const rightParts = [];
|
|
206
|
+
if (usage.provider)
|
|
207
|
+
rightParts.push(`(${usage.provider})`);
|
|
208
|
+
if (usage.model) {
|
|
209
|
+
let modelStr = usage.model;
|
|
210
|
+
if (usage.thinkingLevel)
|
|
211
|
+
modelStr += ` • ${usage.thinkingLevel}`;
|
|
212
|
+
rightParts.push(modelStr);
|
|
213
|
+
}
|
|
214
|
+
const rightContent = rightParts.join(' ');
|
|
215
|
+
const leftLen = leftContent.length;
|
|
216
|
+
const rightLen = rightContent.length;
|
|
217
|
+
const minPad = 2;
|
|
218
|
+
let line;
|
|
219
|
+
if (leftLen + minPad + rightLen <= w) {
|
|
220
|
+
const pad = ' '.repeat(w - leftLen - rightLen);
|
|
221
|
+
line = leftContent + pad + rightContent;
|
|
222
|
+
}
|
|
223
|
+
else if (leftLen + minPad <= w) {
|
|
224
|
+
const avail = w - leftLen - minPad;
|
|
225
|
+
const truncRight = rightContent.slice(0, avail);
|
|
226
|
+
const pad = ' '.repeat(w - leftLen - truncRight.length);
|
|
227
|
+
line = leftContent + pad + truncRight;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
line = leftContent;
|
|
231
|
+
}
|
|
232
|
+
process.stderr.write(`${theme.dim(line)}\n`);
|
|
233
|
+
}
|
|
234
|
+
// ─── StreamRenderer ───
|
|
235
|
+
export class StreamRenderer {
|
|
236
|
+
inThinking = false;
|
|
237
|
+
inText = false;
|
|
238
|
+
textAccum = '';
|
|
239
|
+
stopSpinner = null;
|
|
240
|
+
mdTheme;
|
|
241
|
+
pendingTools = new Map();
|
|
242
|
+
toolResultLines;
|
|
243
|
+
constructor(toolResultLines = 10) {
|
|
244
|
+
this.mdTheme = createMarkdownTheme();
|
|
245
|
+
this.toolResultLines = toolResultLines;
|
|
246
|
+
}
|
|
247
|
+
handleEvent(event) {
|
|
248
|
+
switch (event.type) {
|
|
249
|
+
case 'thinking_start':
|
|
250
|
+
this.onThinkingStart();
|
|
251
|
+
break;
|
|
252
|
+
case 'thinking_delta':
|
|
253
|
+
this.onThinkingDelta(event.delta);
|
|
254
|
+
break;
|
|
255
|
+
case 'thinking_end':
|
|
256
|
+
this.onThinkingEnd();
|
|
257
|
+
break;
|
|
258
|
+
case 'text_start':
|
|
259
|
+
this.onTextStart();
|
|
260
|
+
break;
|
|
261
|
+
case 'text_delta':
|
|
262
|
+
this.onTextDelta(event.delta);
|
|
263
|
+
break;
|
|
264
|
+
case 'text_end':
|
|
265
|
+
this.onTextEnd();
|
|
266
|
+
break;
|
|
267
|
+
case 'tool_start':
|
|
268
|
+
this.onToolStart(event.toolCallId, event.toolName, event.args);
|
|
269
|
+
break;
|
|
270
|
+
case 'tool_update':
|
|
271
|
+
break;
|
|
272
|
+
case 'tool_end':
|
|
273
|
+
this.onToolEnd(event.toolCallId, event.toolName, event.result, event.isError);
|
|
274
|
+
break;
|
|
275
|
+
case 'turn_end':
|
|
276
|
+
break;
|
|
277
|
+
case 'compaction_start':
|
|
278
|
+
this.onCompactionStart(event.reason);
|
|
279
|
+
break;
|
|
280
|
+
case 'compaction_end':
|
|
281
|
+
this.onCompactionEnd(event.summary, event.aborted, event.error);
|
|
282
|
+
break;
|
|
283
|
+
case 'agent_done':
|
|
284
|
+
this.onDone(event.durationMs, event.usage);
|
|
285
|
+
break;
|
|
286
|
+
case 'agent_error':
|
|
287
|
+
this.onError(event.error);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
showSpinner() {
|
|
292
|
+
process.stderr.write('\x1b[?25l'); // hide cursor
|
|
293
|
+
this.stopSpinner = startSpinner('Working...');
|
|
294
|
+
}
|
|
295
|
+
// ─── Thinking ───
|
|
296
|
+
onThinkingStart() {
|
|
297
|
+
this.clearSpinner();
|
|
298
|
+
this.inThinking = true;
|
|
299
|
+
process.stderr.write('\n');
|
|
300
|
+
}
|
|
301
|
+
onThinkingDelta(delta) {
|
|
302
|
+
if (!this.inThinking)
|
|
303
|
+
return;
|
|
304
|
+
process.stderr.write(theme.thinkingText(delta));
|
|
305
|
+
}
|
|
306
|
+
onThinkingEnd() {
|
|
307
|
+
if (!this.inThinking)
|
|
308
|
+
return;
|
|
309
|
+
this.inThinking = false;
|
|
310
|
+
process.stderr.write('\n');
|
|
311
|
+
this.restartSpinner();
|
|
312
|
+
}
|
|
313
|
+
// ─── Text ───
|
|
314
|
+
onTextStart() {
|
|
315
|
+
this.clearSpinner();
|
|
316
|
+
this.inText = true;
|
|
317
|
+
this.textAccum = '';
|
|
318
|
+
process.stderr.write('\n');
|
|
319
|
+
}
|
|
320
|
+
onTextDelta(delta) {
|
|
321
|
+
if (!this.inText)
|
|
322
|
+
return;
|
|
323
|
+
this.textAccum += delta;
|
|
324
|
+
process.stderr.write(delta);
|
|
325
|
+
}
|
|
326
|
+
onTextEnd() {
|
|
327
|
+
if (!this.inText)
|
|
328
|
+
return;
|
|
329
|
+
this.inText = false;
|
|
330
|
+
const raw = this.textAccum.trim();
|
|
331
|
+
if (!raw) {
|
|
332
|
+
const linesToErase = this.countCursorLinesFromStart(this.textAccum);
|
|
333
|
+
if (linesToErase > 0) {
|
|
334
|
+
process.stderr.write(`\x1b[${linesToErase}A`);
|
|
335
|
+
}
|
|
336
|
+
process.stderr.write('\x1b[1G\x1b[0J');
|
|
337
|
+
this.textAccum = '';
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const linesToErase = this.countCursorLinesFromStart(this.textAccum);
|
|
341
|
+
if (linesToErase > 0) {
|
|
342
|
+
process.stderr.write(`\x1b[${linesToErase}A`);
|
|
343
|
+
}
|
|
344
|
+
process.stderr.write('\x1b[1G\x1b[0J');
|
|
345
|
+
const md = new Markdown(raw, 1, 0, this.mdTheme);
|
|
346
|
+
flush(md);
|
|
347
|
+
this.textAccum = '';
|
|
348
|
+
this.restartSpinner();
|
|
349
|
+
}
|
|
350
|
+
// ─── Tool calls ───
|
|
351
|
+
onToolStart(toolCallId, toolName, args) {
|
|
352
|
+
this.clearSpinner();
|
|
353
|
+
const title = formatToolTitle(toolName, args);
|
|
354
|
+
this.pendingTools.set(toolCallId, title);
|
|
355
|
+
this.stopSpinner = startSpinner(title);
|
|
356
|
+
}
|
|
357
|
+
onToolEnd(toolCallId, _toolName, result, isError) {
|
|
358
|
+
this.clearSpinner();
|
|
359
|
+
const title = this.pendingTools.get(toolCallId) || '';
|
|
360
|
+
this.pendingTools.delete(toolCallId);
|
|
361
|
+
const bgFunc = isError ? toolBg.error : toolBg.success;
|
|
362
|
+
const text = extractResultText(result);
|
|
363
|
+
const box = new Box(1, 1, bgFunc);
|
|
364
|
+
box.addChild(new Text(bold(title), 0, 0));
|
|
365
|
+
if (text) {
|
|
366
|
+
const allLines = text.split('\n');
|
|
367
|
+
const display = allLines.length > this.toolResultLines
|
|
368
|
+
? [
|
|
369
|
+
...allLines.slice(0, this.toolResultLines),
|
|
370
|
+
theme.muted(` ... (${allLines.length - this.toolResultLines} more lines)`),
|
|
371
|
+
].join('\n')
|
|
372
|
+
: text;
|
|
373
|
+
box.addChild(new Text(theme.toolOutput(display), 0, 0));
|
|
374
|
+
}
|
|
375
|
+
else if (isError) {
|
|
376
|
+
box.addChild(new Text(theme.error('(error)'), 0, 0));
|
|
377
|
+
}
|
|
378
|
+
process.stderr.write('\n');
|
|
379
|
+
flush(box);
|
|
380
|
+
this.restartSpinner();
|
|
381
|
+
}
|
|
382
|
+
// ─── Auto-compaction ───
|
|
383
|
+
onCompactionStart(reason) {
|
|
384
|
+
this.clearSpinner();
|
|
385
|
+
const reasonText = reason === 'overflow'
|
|
386
|
+
? 'Context overflow — auto-compacting...'
|
|
387
|
+
: 'Auto-compacting...';
|
|
388
|
+
this.stopSpinner = startSpinner(reasonText);
|
|
389
|
+
}
|
|
390
|
+
onCompactionEnd(summary, aborted, error) {
|
|
391
|
+
this.clearSpinner();
|
|
392
|
+
if (aborted) {
|
|
393
|
+
process.stderr.write(`${theme.warning('⚠')} compaction cancelled\n`);
|
|
394
|
+
}
|
|
395
|
+
else if (error) {
|
|
396
|
+
process.stderr.write(`${theme.error('✗')} compaction failed: ${error}\n`);
|
|
397
|
+
}
|
|
398
|
+
else if (summary) {
|
|
399
|
+
const short = summary.length > 80 ? `${summary.slice(0, 77)}...` : summary;
|
|
400
|
+
process.stderr.write(`${theme.accent('●')} compacted: ${theme.muted(short)}\n`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// ─── Done / Error / Interrupted ───
|
|
404
|
+
showCursor() {
|
|
405
|
+
process.stderr.write('\x1b[?25h');
|
|
406
|
+
}
|
|
407
|
+
onDone(durationMs, usage) {
|
|
408
|
+
this.clearSpinner();
|
|
409
|
+
this.showCursor();
|
|
410
|
+
process.stderr.write('\n');
|
|
411
|
+
renderStatusBar(durationMs, usage);
|
|
412
|
+
process.stderr.write('\n');
|
|
413
|
+
}
|
|
414
|
+
onError(msg) {
|
|
415
|
+
this.clearSpinner();
|
|
416
|
+
this.showCursor();
|
|
417
|
+
process.stderr.write(`\n ${theme.error(`Error: ${msg}`)}\n\n`);
|
|
418
|
+
}
|
|
419
|
+
printInterrupted() {
|
|
420
|
+
this.clearSpinner();
|
|
421
|
+
this.showCursor();
|
|
422
|
+
if (this.inThinking) {
|
|
423
|
+
process.stderr.write('\n');
|
|
424
|
+
this.inThinking = false;
|
|
425
|
+
}
|
|
426
|
+
if (this.inText) {
|
|
427
|
+
process.stderr.write('\n');
|
|
428
|
+
this.inText = false;
|
|
429
|
+
}
|
|
430
|
+
process.stderr.write(`\n ${theme.error('Operation aborted')}\n\n`);
|
|
431
|
+
}
|
|
432
|
+
// ─── Internal ───
|
|
433
|
+
clearSpinner() {
|
|
434
|
+
if (this.stopSpinner) {
|
|
435
|
+
this.stopSpinner();
|
|
436
|
+
this.stopSpinner = null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
restartSpinner() {
|
|
440
|
+
this.stopSpinner = startSpinner('Working...');
|
|
441
|
+
}
|
|
442
|
+
countCursorLinesFromStart(text) {
|
|
443
|
+
const w = termWidth();
|
|
444
|
+
const segments = text.split('\n');
|
|
445
|
+
const newlineCount = segments.length - 1;
|
|
446
|
+
let wrapExtra = 0;
|
|
447
|
+
for (const seg of segments) {
|
|
448
|
+
const cols = visibleWidth(seg);
|
|
449
|
+
if (cols > w) {
|
|
450
|
+
wrapExtra += Math.ceil(cols / w) - 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return newlineCount + wrapExtra;
|
|
454
|
+
}
|
|
455
|
+
}
|
package/dist/strip.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI stripping + line-level truncation + alt screen detection.
|
|
3
|
+
*
|
|
4
|
+
* Used for the output segment (C→D).
|
|
5
|
+
*/
|
|
6
|
+
// Matches all ANSI escape sequences (CSI, OSC, ESC single-char, etc.)
|
|
7
|
+
// and control chars: \x08 (BS), \x0d (CR), \x00-\x1f except \x0a (LF)
|
|
8
|
+
const ANSI_RE = /\x1b(?:\[[0-9;?]*[a-zA-Z@`]|\][^\x07]*(?:\x07|\x1b\\)|\([A-Z0-9]|[=>DEHMNOPZ78])|[\x00-\x09\x0b-\x0d\x0e-\x1f\x7f]/g;
|
|
9
|
+
/** Alt screen enter: \e[?1049h, \e[?47h, \e[?1047h */
|
|
10
|
+
const ALT_SCREEN_RE = /\x1b\[\?(1049|47|1047)h/;
|
|
11
|
+
export function isAltScreen(data) {
|
|
12
|
+
return ALT_SCREEN_RE.test(data);
|
|
13
|
+
}
|
|
14
|
+
export function stripAnsi(data) {
|
|
15
|
+
return data.replace(ANSI_RE, '');
|
|
16
|
+
}
|
|
17
|
+
export const DEFAULT_TRUNCATE = {
|
|
18
|
+
headLines: 50,
|
|
19
|
+
tailLines: 30,
|
|
20
|
+
maxLineWidth: 512,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Line-level truncation: keep head + tail lines, truncate the middle.
|
|
24
|
+
* Also truncates individual long lines.
|
|
25
|
+
*
|
|
26
|
+
* Head > tail ratio: command output typically starts with structured info
|
|
27
|
+
* (headers, column names, first results); tail preserves errors and final results.
|
|
28
|
+
*/
|
|
29
|
+
export function truncateLines(text, opts = DEFAULT_TRUNCATE) {
|
|
30
|
+
let lines = text.split('\n');
|
|
31
|
+
// Per-line truncation
|
|
32
|
+
lines = lines.map((line) => {
|
|
33
|
+
if (line.length > opts.maxLineWidth) {
|
|
34
|
+
return `${line.slice(0, opts.maxLineWidth)} ...`;
|
|
35
|
+
}
|
|
36
|
+
return line;
|
|
37
|
+
});
|
|
38
|
+
const total = lines.length;
|
|
39
|
+
const max = opts.headLines + opts.tailLines;
|
|
40
|
+
if (total <= max)
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
const head = lines.slice(0, opts.headLines);
|
|
43
|
+
const tail = lines.slice(-opts.tailLines);
|
|
44
|
+
const omitted = total - max;
|
|
45
|
+
return [...head, `... (${omitted} lines truncated) ...`, ...tail].join('\n');
|
|
46
|
+
}
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme — ANSI color utilities and color palette.
|
|
3
|
+
*
|
|
4
|
+
* Raw ANSI helpers (matching pi's Theme approach) and color definitions
|
|
5
|
+
* used across all rendering functions.
|
|
6
|
+
*/
|
|
7
|
+
// ─── Raw ANSI helpers ───
|
|
8
|
+
function hexToRgb(hex) {
|
|
9
|
+
const h = hex.replace('#', '');
|
|
10
|
+
return [
|
|
11
|
+
parseInt(h.substring(0, 2), 16),
|
|
12
|
+
parseInt(h.substring(2, 4), 16),
|
|
13
|
+
parseInt(h.substring(4, 6), 16),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
export function fg(hex) {
|
|
17
|
+
const [r, g, b] = hexToRgb(hex);
|
|
18
|
+
const open = `\x1b[38;2;${r};${g};${b}m`;
|
|
19
|
+
return (s) => `${open}${s}\x1b[39m`;
|
|
20
|
+
}
|
|
21
|
+
export function bg(hex) {
|
|
22
|
+
const [r, g, b] = hexToRgb(hex);
|
|
23
|
+
const open = `\x1b[48;2;${r};${g};${b}m`;
|
|
24
|
+
return (s) => `${open}${s}\x1b[49m`;
|
|
25
|
+
}
|
|
26
|
+
export const bold = (s) => `\x1b[1m${s}\x1b[22m`;
|
|
27
|
+
export const italic = (s) => `\x1b[3m${s}\x1b[23m`;
|
|
28
|
+
export const dim = (s) => `\x1b[2m${s}\x1b[22m`;
|
|
29
|
+
export const underline = (s) => `\x1b[4m${s}\x1b[24m`;
|
|
30
|
+
export const strikethrough = (s) => `\x1b[9m${s}\x1b[29m`;
|
|
31
|
+
// ─── Theme colors (pi dark.json) ───
|
|
32
|
+
export const theme = {
|
|
33
|
+
accent: fg('#8abeb7'),
|
|
34
|
+
success: fg('#b5bd68'),
|
|
35
|
+
error: fg('#cc6666'),
|
|
36
|
+
warning: fg('#ffff00'),
|
|
37
|
+
muted: fg('#808080'),
|
|
38
|
+
dim: fg('#666666'),
|
|
39
|
+
thinkingText: (s) => italic(fg('#808080')(s)),
|
|
40
|
+
toolOutput: fg('#808080'),
|
|
41
|
+
};
|
|
42
|
+
export const toolBg = {
|
|
43
|
+
success: bg('#283228'),
|
|
44
|
+
error: bg('#3c2828'),
|
|
45
|
+
};
|
|
46
|
+
export const TAG = dim(fg('#00d7ff')('pish')); // cyan dim
|
|
47
|
+
export function createMarkdownTheme() {
|
|
48
|
+
return {
|
|
49
|
+
heading: (s) => bold(fg('#f0c674')(s)),
|
|
50
|
+
link: fg('#81a2be'),
|
|
51
|
+
linkUrl: fg('#666666'),
|
|
52
|
+
code: fg('#8abeb7'),
|
|
53
|
+
codeBlock: fg('#b5bd68'),
|
|
54
|
+
codeBlockBorder: fg('#808080'),
|
|
55
|
+
quote: fg('#808080'),
|
|
56
|
+
quoteBorder: fg('#808080'),
|
|
57
|
+
hr: fg('#808080'),
|
|
58
|
+
listBullet: fg('#8abeb7'),
|
|
59
|
+
bold,
|
|
60
|
+
italic,
|
|
61
|
+
strikethrough,
|
|
62
|
+
underline,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/vterm.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual terminal replay for the prompt segment.
|
|
3
|
+
*
|
|
4
|
+
* Uses @xterm/headless to replay raw PTY data and extract the final
|
|
5
|
+
* displayed text. Handles readline editing, ANSI colors, cursor
|
|
6
|
+
* movement, etc.
|
|
7
|
+
*
|
|
8
|
+
* Reuses a single Terminal instance (reset + write) to avoid
|
|
9
|
+
* repeated construction overhead.
|
|
10
|
+
*/
|
|
11
|
+
// @xterm/headless 6.0.0 lacks an "exports" field in package.json,
|
|
12
|
+
// so Node ESM resolution only sees "main" (CJS). Use createRequire
|
|
13
|
+
// as a workaround until the next stable release (#5632).
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { Terminal } = require('@xterm/headless');
|
|
17
|
+
// Lazily initialized shared Terminal instance
|
|
18
|
+
let sharedTerm = null;
|
|
19
|
+
function getTerm(cols, rows) {
|
|
20
|
+
if (sharedTerm) {
|
|
21
|
+
if (sharedTerm.cols !== cols || sharedTerm.rows !== rows) {
|
|
22
|
+
sharedTerm.resize(cols, rows);
|
|
23
|
+
}
|
|
24
|
+
sharedTerm.reset();
|
|
25
|
+
return sharedTerm;
|
|
26
|
+
}
|
|
27
|
+
sharedTerm = new Terminal({
|
|
28
|
+
cols,
|
|
29
|
+
rows,
|
|
30
|
+
scrollback: 200,
|
|
31
|
+
allowProposedApi: true,
|
|
32
|
+
logLevel: 'off',
|
|
33
|
+
});
|
|
34
|
+
return sharedTerm;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Replay raw PTY data and return the final displayed text.
|
|
38
|
+
* Digests readline editing, ANSI color, cursor operations, alt screen, etc.
|
|
39
|
+
*/
|
|
40
|
+
export function vtermReplay(data, cols = 120, rows = 30) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const term = getTerm(cols, rows);
|
|
43
|
+
term.write(data, () => {
|
|
44
|
+
const buf = term.buffer.active;
|
|
45
|
+
const lines = [];
|
|
46
|
+
// scrollback (baseY lines scrolled off top) + viewport (up to cursorY)
|
|
47
|
+
const totalLines = buf.baseY + buf.cursorY + 1;
|
|
48
|
+
for (let i = 0; i < totalLines; i++) {
|
|
49
|
+
const line = buf.getLine(i)?.translateToString(true);
|
|
50
|
+
lines.push(line?.trimEnd() ?? '');
|
|
51
|
+
}
|
|
52
|
+
// Trim trailing empty lines
|
|
53
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
54
|
+
lines.pop();
|
|
55
|
+
}
|
|
56
|
+
resolve(lines.join('\n'));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aerode/pish",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "A shell-first pi coding agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "dacapoday",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dacapoday/pish.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/dacapoday/pish",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/dacapoday/pish/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["shell", "ai", "coding-agent", "bash", "zsh", "pi", "cli"],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"pish": "dist/main.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"postinstall": "sh postinstall.sh",
|
|
25
|
+
"build": "tsc && tsc -p tsconfig.test.json && chmod +x dist/main.js",
|
|
26
|
+
"clean": "rm -rf dist",
|
|
27
|
+
"start": "node dist/main.js",
|
|
28
|
+
"dev": "tsc --watch",
|
|
29
|
+
"lint": "biome check src/ test/",
|
|
30
|
+
"test": "bash test/run_tests.sh",
|
|
31
|
+
"test:unit": "npx tsx --test test/unit/*.test.ts"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"!dist/test",
|
|
36
|
+
"README.md",
|
|
37
|
+
"postinstall.sh"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@mariozechner/pi-tui": "^0.64.0",
|
|
41
|
+
"@xterm/headless": "^6.0.0",
|
|
42
|
+
"node-pty": "^1.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@biomejs/biome": "^2.4.10",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"typescript": "^5.0.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/postinstall.sh
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# node-pty@1.1.0 ships prebuilt binaries without the execute bit set, which
|
|
3
|
+
# causes "posix_spawnp failed" at runtime on macOS/Linux. Fix it here.
|
|
4
|
+
PREBUILDS="$PWD/node_modules/node-pty/prebuilds"
|
|
5
|
+
if [ -d "$PREBUILDS" ]; then
|
|
6
|
+
find "$PREBUILDS" \( -name "spawn-helper" -o -name "*.node" \) -exec chmod +x {} \; 2>/dev/null || true
|
|
7
|
+
fi
|