phewsh 0.15.4 → 0.15.6

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.
@@ -511,6 +511,7 @@ async function main() {
511
511
  turnInFlight = true;
512
512
  const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input), { model: harnessModel });
513
513
  turnInFlight = false;
514
+ userCancelled = false; // a SIGTERM the harness rode out must not mislabel the next turn
514
515
  messages.push({ role: 'user', content: input });
515
516
  messages.push({ role: 'assistant', content: (output || '').trim() });
516
517
  recordSessionEvent(harnessId, projectName, 'task_complete', {
@@ -647,16 +648,22 @@ async function main() {
647
648
  return estimateTokens(`${systemPrompt}\n${conversation}`);
648
649
  }
649
650
 
651
+ // One quiet line, Claude Code-bar style: route + model · context gauge ·
652
+ // mode. Hints live in /help — the rail is glanceable state, not a manual.
650
653
  function renderStatusRail() {
651
654
  if (!process.stdout.isTTY) return;
652
655
  const folder = relativeFolder(process.cwd(), os.homedir());
653
- const contextLabel = intentFiles.length > 0
654
- ? `${intentFiles.length} intent file${intentFiles.length === 1 ? '' : 's'}`
655
- : 'no project context';
656
- const routeName = routeLabel(route, config);
657
- const tokens = formatTokenCount(currentContextTokens());
658
- console.log(` ${slate(folder)} ${slate('·')} ${cream(routeName)} ${slate('·')} ${sage(contextLabel)} ${slate('·')} ${sage(`~${tokens} ctx tokens`)}`);
659
- console.log(` ${slate('/help commands · /use route · /context memory · Ctrl+O last paste · Esc cancel')}`);
656
+ const routeName = route?.type === 'harness' ? HARNESSES[route.id].label : routeLabel(route, config);
657
+ const model = route?.type === 'harness'
658
+ ? (harnessModel || 'default model')
659
+ : modelName(currentModel);
660
+ const tokens = currentContextTokens();
661
+ const pct = Math.min(99, Math.round((tokens / 200000) * 100));
662
+ const bar = '█'.repeat(Math.max(1, Math.round(pct / 10))) + '░'.repeat(10 - Math.max(1, Math.round(pct / 10)));
663
+ const modeLabel = sessionMode
664
+ ? Object.values(INTENT_MODES).find(m => m.id === sessionMode)?.label.toLowerCase()
665
+ : 'open';
666
+ console.log(` ${slate(folder)} ${slate('│')} ${cream(routeName)} ${slate(model)} ${slate('│')} ${slate(bar)} ${slate(pct + '%')} ${slate('│')} ${sage('⏵ ' + modeLabel)} ${slate('(shift+tab)')}`);
660
667
  }
661
668
 
662
669
  const readlinePrompt = rl.prompt.bind(rl);
@@ -714,9 +721,72 @@ async function main() {
714
721
  };
715
722
  }
716
723
 
724
+ // ── Bracketed paste: like Claude Code, a paste lands in the input line as
725
+ // a collapsed placeholder and NEVER auto-submits — Enter sends it. The
726
+ // terminal marks paste boundaries (\x1b[200~ … \x1b[201~); Node's keypress
727
+ // decoder surfaces them as paste-start/paste-end. While pasting we detach
728
+ // readline so embedded newlines can't fire 'line' events.
729
+ const PASTE_ON = '\x1b[?2004h';
730
+ const PASTE_OFF = '\x1b[?2004l';
731
+ let pasting = false;
732
+ let pasteChunks = [];
733
+ let detachedListeners = null;
734
+ let pasteCounter = 0;
735
+ const pendingPastes = new Map();
736
+
737
+ function pasteMode(on) {
738
+ if (process.stdout.isTTY) process.stdout.write(on ? PASTE_ON : PASTE_OFF);
739
+ }
740
+
741
+ // Substitute placeholders back to the real pasted text at submit time.
742
+ function expandPastes(input) {
743
+ let out = input;
744
+ for (const [tag, text] of pendingPastes) {
745
+ if (out.includes(tag)) {
746
+ out = out.split(tag).join(text);
747
+ pendingPastes.delete(tag);
748
+ }
749
+ }
750
+ return out;
751
+ }
752
+
753
+ let wasSpecialInput = false; // recolor must also fire when the token STOPS matching
754
+
717
755
  if (process.stdin.isTTY) {
718
- process.stdin.prependListener('keypress', (str, key) => {
756
+ const phewshKeypress = (str, key) => {
719
757
  try {
758
+ // Paste interception comes first — everything inside the paste is data.
759
+ if (key && key.name === 'paste-start') {
760
+ pasting = true;
761
+ pasteChunks = [];
762
+ detachedListeners = process.stdin.listeners('keypress').filter(l => l !== phewshKeypress);
763
+ for (const l of detachedListeners) process.stdin.removeListener('keypress', l);
764
+ return;
765
+ }
766
+ if (pasting) {
767
+ if (key && key.name === 'paste-end') {
768
+ pasting = false;
769
+ for (const l of detachedListeners || []) process.stdin.on('keypress', l);
770
+ detachedListeners = null;
771
+ const text = pasteChunks.join('');
772
+ pasteChunks = [];
773
+ if (!text) return;
774
+ const lineCount = text.split('\n').length;
775
+ if (lineCount > 1 || text.length > 200) {
776
+ pasteCounter++;
777
+ const tag = `[paste #${pasteCounter}: ${text.length.toLocaleString('en-US')} chars, ${lineCount} lines]`;
778
+ pendingPastes.set(tag, text);
779
+ lastPaste = text;
780
+ rl.write(tag);
781
+ } else {
782
+ rl.write(text);
783
+ }
784
+ return;
785
+ }
786
+ pasteChunks.push(str !== undefined && str !== null ? String(str) : (key && key.sequence) || '');
787
+ return;
788
+ }
789
+
720
790
  if (key?.ctrl && key.name === 'o' && lastPaste) {
721
791
  setImmediate(() => {
722
792
  rl.line = '';
@@ -729,10 +799,22 @@ async function main() {
729
799
  });
730
800
  return;
731
801
  }
802
+ // shift+tab cycles the session mode — open → build → research → decide → review
803
+ if (key && key.name === 'tab' && key.shift) {
804
+ const ids = [null, ...Object.values(INTENT_MODES).map(m => m.id)];
805
+ const next = ids[(ids.indexOf(sessionMode) + 1) % ids.length];
806
+ sessionMode = next;
807
+ const label = next ? Object.values(INTENT_MODES).find(m => m.id === next).label : 'Open';
808
+ process.stdout.write('\x1b[2K\r');
809
+ console.log(` ${teal('⏵')} ${sage('mode:')} ${cream(label.toLowerCase())}${next ? slate(' — ' + 'shapes how routes respond') : slate(' — no slant')}`);
810
+ rl.prompt();
811
+ return;
812
+ }
732
813
  // ESC: cancel an in-flight turn, or clear the input line.
733
814
  if (key && key.name === 'escape') {
734
815
  if (turnInFlight) {
735
816
  userCancelled = true;
817
+ process.stdout.write('\n \x1b[38;5;247mcancelling…\x1b[0m\n');
736
818
  if (turnAbort) turnAbort.abort();
737
819
  cancelActive();
738
820
  } else if (rl.line) {
@@ -742,17 +824,23 @@ async function main() {
742
824
  }
743
825
  return;
744
826
  }
745
- // Re-render so token coloring tracks edits (and un-colors when it
746
- // stops matching a known command).
827
+ // Re-render so token coloring tracks edits including the keystroke
828
+ // where the token stops matching and must un-color.
747
829
  const cur = rl.line || '';
748
- if (cur[0] === '/' || cur[0] === '@') rl._refreshLine();
830
+ const special = cur[0] === '/' || cur[0] === '@';
831
+ if (special || wasSpecialInput) rl._refreshLine();
832
+ wasSpecialInput = special;
749
833
  } catch { /* never break input */ }
750
- });
834
+ };
835
+ process.stdin.prependListener('keypress', phewshKeypress);
836
+ pasteMode(true);
837
+ process.on('exit', () => pasteMode(false)); // never leave the terminal in paste mode
751
838
  }
752
839
 
753
840
  rl.prompt();
754
841
 
755
842
  async function handleInput(input) {
843
+ input = expandPastes(input);
756
844
 
757
845
  // A bare number right after a route failure picks the fallback
758
846
  if (awaitingFallback) {
@@ -1450,10 +1538,12 @@ async function main() {
1450
1538
  console.log('');
1451
1539
  console.log(` ${teal('●')} ${sage('Handing you to the guided update')} ${slate('— exit to come back to phewsh')}`);
1452
1540
  console.log('');
1541
+ pasteMode(false);
1453
1542
  rl.pause();
1454
1543
  const { spawnSync } = require('child_process');
1455
1544
  spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'phewsh.js'), 'clarify'], { stdio: 'inherit' });
1456
1545
  rl.resume();
1546
+ pasteMode(true);
1457
1547
  console.log('');
1458
1548
  console.log(` ${teal('●')} ${sage('Back in phewsh.')} ${slate('/intent view to see the result — agents pick it up automatically')}`);
1459
1549
  console.log('');
@@ -1762,10 +1852,12 @@ async function main() {
1762
1852
  console.log(` ${slate('your .intent/ context rides along via CLAUDE.md')}`);
1763
1853
  }
1764
1854
  console.log('');
1855
+ pasteMode(false);
1765
1856
  rl.pause();
1766
1857
  const { spawnSync } = require('child_process');
1767
1858
  const res = spawnSync(h.bin, [], { stdio: 'inherit' });
1768
1859
  rl.resume();
1860
+ pasteMode(true);
1769
1861
  recordSessionEvent(target, projectName, 'task_complete', {
1770
1862
  taskId: decisionId, success: res.status === 0, summary: `interactive ${h.label} session`,
1771
1863
  });
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"