phewsh 0.15.3 → 0.15.5

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/README.md CHANGED
@@ -84,6 +84,17 @@ Inside the shell:
84
84
  /help All commands
85
85
  ```
86
86
 
87
+ The prompt includes a compact status rail with the current folder, route,
88
+ loaded `.intent/` files, and approximate context size. Multi-line pastes and
89
+ single-line pastes of 300+ characters collapse after submission:
90
+
91
+ ```text
92
+ [pasted 1,284 chars · 12 lines · Ctrl+O to expand]
93
+ ```
94
+
95
+ Press `Ctrl+O` to inspect the last submitted paste and `Esc` to clear input or
96
+ cancel an in-flight provider turn.
97
+
87
98
  ## What it does
88
99
 
89
100
  Creates three structured artifacts in `.intent/`:
@@ -22,6 +22,14 @@ const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES }
22
22
  const { recordSessionEvent } = require('../lib/receipts-data');
23
23
  const configFile = require('../lib/config-file');
24
24
  const { createFailureTracker, createLineDispatcher } = require('../lib/session-input');
25
+ const {
26
+ echoedRows,
27
+ estimateTokens,
28
+ formatPasteSummary,
29
+ formatTokenCount,
30
+ relativeFolder,
31
+ shouldCollapsePaste,
32
+ } = require('../lib/session-display');
25
33
  const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
26
34
 
27
35
  // Brand palette shortcuts
@@ -503,6 +511,7 @@ async function main() {
503
511
  turnInFlight = true;
504
512
  const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input), { model: harnessModel });
505
513
  turnInFlight = false;
514
+ userCancelled = false; // a SIGTERM the harness rode out must not mislabel the next turn
506
515
  messages.push({ role: 'user', content: input });
507
516
  messages.push({ role: 'assistant', content: (output || '').trim() });
508
517
  recordSessionEvent(harnessId, projectName, 'task_complete', {
@@ -631,6 +640,39 @@ async function main() {
631
640
  prompt: ` ${teal('phewsh')} ${sage('>')} `,
632
641
  historySize: 100,
633
642
  });
643
+ const promptText = ` phewsh > `;
644
+ let lastPaste = null;
645
+
646
+ function currentContextTokens() {
647
+ const conversation = messages.map(message => message.content).join('\n');
648
+ return estimateTokens(`${systemPrompt}\n${conversation}`);
649
+ }
650
+
651
+ function renderStatusRail() {
652
+ if (!process.stdout.isTTY) return;
653
+ const folder = relativeFolder(process.cwd(), os.homedir());
654
+ const contextLabel = intentFiles.length > 0
655
+ ? `${intentFiles.length} intent file${intentFiles.length === 1 ? '' : 's'}`
656
+ : 'no project context';
657
+ const routeName = routeLabel(route, config);
658
+ const tokens = formatTokenCount(currentContextTokens());
659
+ console.log(` ${slate(folder)} ${slate('·')} ${cream(routeName)} ${slate('·')} ${sage(contextLabel)} ${slate('·')} ${sage(`~${tokens} ctx tokens`)}`);
660
+ console.log(` ${slate('/help commands · /use route · /context memory · Ctrl+O last paste · Esc cancel')}`);
661
+ }
662
+
663
+ const readlinePrompt = rl.prompt.bind(rl);
664
+ rl.prompt = function promptWithStatusRail(preserveCursor) {
665
+ renderStatusRail();
666
+ return readlinePrompt(preserveCursor);
667
+ };
668
+
669
+ function collapsePastedEcho(lines, input) {
670
+ if (!process.stdout.isTTY || !shouldCollapsePaste(lines, input)) return;
671
+ const rows = echoedRows(lines, promptText, process.stdout.columns || 80);
672
+ for (let i = 0; i < rows; i++) process.stdout.write('\x1b[1A\x1b[2K\r');
673
+ lastPaste = input;
674
+ console.log(` ${peach(formatPasteSummary(input, lines.length))}`);
675
+ }
634
676
 
635
677
  // Live input coloring — like Claude Code: text stays normal, and only a
636
678
  // RECOGNIZED leading /command (or @harness) token turns teal (peach for @)
@@ -674,12 +716,25 @@ async function main() {
674
716
  }
675
717
 
676
718
  if (process.stdin.isTTY) {
677
- process.stdin.on('keypress', (str, key) => {
719
+ process.stdin.prependListener('keypress', (str, key) => {
678
720
  try {
721
+ if (key?.ctrl && key.name === 'o' && lastPaste) {
722
+ setImmediate(() => {
723
+ rl.line = '';
724
+ rl.cursor = 0;
725
+ process.stdout.write('\x1b[2K\r');
726
+ console.log(` ${b(cream('Last paste'))} ${slate(`(${lastPaste.length.toLocaleString('en-US')} chars)`)}`);
727
+ console.log(lastPaste.split('\n').map(line => ` ${line}`).join('\n'));
728
+ console.log('');
729
+ rl.prompt();
730
+ });
731
+ return;
732
+ }
679
733
  // ESC: cancel an in-flight turn, or clear the input line.
680
734
  if (key && key.name === 'escape') {
681
735
  if (turnInFlight) {
682
736
  userCancelled = true;
737
+ process.stdout.write('\n \x1b[38;5;247mcancelling…\x1b[0m\n');
683
738
  if (turnAbort) turnAbort.abort();
684
739
  cancelActive();
685
740
  } else if (rl.line) {
@@ -1827,6 +1882,7 @@ async function main() {
1827
1882
  }
1828
1883
 
1829
1884
  const lineDispatcher = createLineDispatcher(handleInput, {
1885
+ onBatch: ({ input, lines }) => collapsePastedEcho(lines, input),
1830
1886
  onNoop: () => rl.prompt(),
1831
1887
  onError: (err) => {
1832
1888
  console.error(`\n ${ember('!')} ${sage('Input failed:')} ${err.message}`);
package/lib/harnesses.js CHANGED
@@ -42,7 +42,17 @@ const ACTIVE_CHILDREN = new Set();
42
42
  function cancelActive() {
43
43
  let n = 0;
44
44
  for (const c of ACTIVE_CHILDREN) {
45
- try { c.kill('SIGTERM'); n++; } catch { /* already gone */ }
45
+ try {
46
+ c._phewshCancelled = true; // close handler rejects even if exit is 0
47
+ c.kill('SIGTERM');
48
+ // Some harnesses (codex) ride out SIGTERM and finish anyway —
49
+ // escalate so esc means esc.
50
+ const t = setTimeout(() => {
51
+ try { if (c.exitCode === null) c.kill('SIGKILL'); } catch { /* gone */ }
52
+ }, 1200);
53
+ if (t.unref) t.unref();
54
+ n++;
55
+ } catch { /* already gone */ }
46
56
  }
47
57
  return n;
48
58
  }
@@ -93,6 +103,7 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
93
103
  child.stderr.on('data', (d) => { stderr += d.toString(); });
94
104
  child.on('close', (code) => {
95
105
  if (!opts.quiet) process.stdout.write('\n');
106
+ if (child._phewshCancelled) return reject(new Error(`${h.label} cancelled`));
96
107
  if (code === 0) resolve(stdout);
97
108
  else reject(new Error(`${h.label} exited ${code}${stderr ? `\n ${stderr.trim().split('\n').slice(-3).join('\n ')}` : ''}`));
98
109
  });
@@ -0,0 +1,54 @@
1
+ const path = require('path');
2
+
3
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
4
+
5
+ function visibleLength(value) {
6
+ return String(value || '').replace(ANSI_RE, '').length;
7
+ }
8
+
9
+ function estimateTokens(value) {
10
+ return Math.max(0, Math.ceil(String(value || '').length / 4));
11
+ }
12
+
13
+ function formatTokenCount(tokens) {
14
+ if (tokens < 1000) return String(tokens);
15
+ return `${(tokens / 1000).toFixed(tokens < 10000 ? 1 : 0)}k`;
16
+ }
17
+
18
+ function shouldCollapsePaste(lines, input, threshold = 300) {
19
+ return lines.length > 1 || input.length >= threshold;
20
+ }
21
+
22
+ function formatPasteSummary(input, lineCount) {
23
+ const chars = input.length.toLocaleString('en-US');
24
+ const lines = lineCount > 1 ? ` · ${lineCount} lines` : '';
25
+ return `[pasted ${chars} chars${lines} · Ctrl+O to expand]`;
26
+ }
27
+
28
+ function echoedRows(lines, prompt, columns = 80) {
29
+ const width = Math.max(20, columns || 80);
30
+ return lines.reduce((total, line, index) => {
31
+ const prefix = index === 0 ? visibleLength(prompt) : 0;
32
+ return total + Math.max(1, Math.ceil((prefix + visibleLength(line)) / width));
33
+ }, 0);
34
+ }
35
+
36
+ function relativeFolder(cwd, home) {
37
+ if (!cwd) return '';
38
+ const relative = home ? path.relative(home, cwd) : cwd;
39
+ if (home && relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
40
+ return `~/${relative}`;
41
+ }
42
+ if (home && relative === '') return '~';
43
+ return cwd;
44
+ }
45
+
46
+ module.exports = {
47
+ echoedRows,
48
+ estimateTokens,
49
+ formatPasteSummary,
50
+ formatTokenCount,
51
+ relativeFolder,
52
+ shouldCollapsePaste,
53
+ visibleLength,
54
+ };
@@ -1,6 +1,7 @@
1
1
  function createLineDispatcher(handleInput, {
2
2
  onError = (err) => { throw err; },
3
3
  onNoop = () => {},
4
+ onBatch = () => {},
4
5
  schedule = setImmediate,
5
6
  } = {}) {
6
7
  let pendingLines = [];
@@ -16,6 +17,7 @@ function createLineDispatcher(handleInput, {
16
17
  onNoop();
17
18
  return;
18
19
  }
20
+ onBatch({ input, lines });
19
21
  chain = chain.then(() => handleInput(input)).catch(onError);
20
22
  }
21
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"
@@ -35,6 +35,9 @@
35
35
  "engines": {
36
36
  "node": ">=18.0.0"
37
37
  },
38
+ "scripts": {
39
+ "test": "node --test test/*.test.js"
40
+ },
38
41
  "dependencies": {
39
42
  "@modelcontextprotocol/sdk": "^1.0.0"
40
43
  }