@hncode/hncode 0.5.0 β†’ 0.5.2

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.
Files changed (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
package/bin/install.cjs CHANGED
@@ -30,35 +30,27 @@ for (const dir of dirs) {
30
30
 
31
31
  // Check if config file exists
32
32
  if (!fs.existsSync(configFile)) {
33
- const defaultConfig = `# hncode Configuration
34
- # Generated by hncode install script
33
+ const defaultConfig = `# hncode configuration
34
+ #
35
+ # Nothing here is required: every value has a default, and the same keys can be set with
36
+ # HNCODE_* environment variables or from /settings once hncode is running.
37
+ #
38
+ # Model and provider are deliberately left out. Naming one here starts hncode on a model
39
+ # you may have no key for, and the first turn fails against that provider instead of
40
+ # asking you to choose. Set up a provider with /provider, then pick a model with /model.
35
41
 
36
- # Default model (change this to your preferred model)
37
- default_model = "gpt-4o-mini"
38
-
39
- # Optional: Add providers directly here
40
- # Uncomment and configure as needed:
41
- #
42
42
  # [providers.openai]
43
43
  # base_url = "https://api.openai.com/v1"
44
- # api_key = "your-api-key-here"
44
+ # api_key = "sk-..." # or set HNCODE_API_KEY
45
45
  # protocol = "openai"
46
-
47
- # Or use environment variables:
48
- # HNCODE_API_KEY="your-key"
49
- # HNCODE_PROVIDER="openai"
50
- # HNCODE_MODEL="gpt-4o-mini"
51
-
52
46
  `;
53
47
 
54
48
  fs.writeFileSync(configFile, defaultConfig);
55
49
  console.log(`\nβœ… Created default configuration at: ${configFile}`);
56
50
  console.log('\nπŸ’‘ Next steps:');
57
- console.log(' 1. Edit ~/.hncode/config.toml with your API key');
58
- console.log(' 2. Or set environment variables:');
59
- console.log(' export HNCODE_API_KEY="your-key"');
60
- console.log(' export HNCODE_PROVIDER="openai"');
61
- console.log(' 3. Run "hncode" to start!');
51
+ console.log(' 1. Start hncode and run /provider to add an API key');
52
+ console.log(' 2. Then /model to pick one');
53
+ console.log(' Both write to the config above, so there is nothing to edit by hand.');
62
54
  } else {
63
55
  console.log(`\nℹ️ Configuration already exists at: ${configFile}`);
64
56
  console.log(' Run "hncode init" to regenerate or edit manually.');
@@ -66,30 +58,18 @@ default_model = "gpt-4o-mini"
66
58
 
67
59
  console.log('\n✨ hncode is ready to use!');
68
60
 
69
- // Try to refresh environment automatically
70
- try {
71
- const platform = os.platform();
72
-
73
- if (platform === 'win32') {
74
- // Windows: Use PowerShell to refresh environment
75
- const { execSync } = require('child_process');
76
- try {
77
- execSync('$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User");', { stdio: 'pipe' });
78
- console.log('βœ“ Environment refreshed automatically (PowerShell)');
79
- } catch (e) {
80
- console.log('ℹ️ To refresh environment in PowerShell:');
81
- console.log(' . "$env:LOCALAPPDATA\\npm\\npm.cmd"');
82
- console.log(' Or simply close and reopen your terminal');
83
- }
84
- } else {
85
- // Unix-like systems
86
- console.log('ℹ️ Please run one of these to refresh environment:');
87
- console.log(' source ~/.zshrc # For Zsh');
88
- console.log(' source ~/.bashrc # For Bash');
89
- console.log(' Or simply close and reopen your terminal');
90
- }
91
- } catch (err) {
92
- console.log('ℹ️ Note: You may need to restart your terminal');
61
+ // Tell the user how to pick up the new command.
62
+ //
63
+ // This used to run PowerShell to prepend the npm global path, and print "Environment
64
+ // refreshed automatically". That cannot work: `execSync` starts a CHILD shell, so the
65
+ // `$env:Path` it sets dies with that process. The claim is gone; the note below is true.
66
+ if (os.platform() === 'win32') {
67
+ console.log('ℹ️ If "hncode" is not found, open a new terminal (the PATH is updated at');
68
+ console.log(' login), or run this in the current one:');
69
+ console.log(' $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" + $env:Path');
70
+ } else {
71
+ console.log('ℹ️ If "hncode" is not found, open a new terminal, or re-read your shell');
72
+ console.log(' configuration: source ~/.zshrc (or ~/.bashrc)');
93
73
  }
94
74
 
95
75
  console.log('');
@@ -25,18 +25,4 @@ if (major < 20) {
25
25
  const platform = os.platform();
26
26
  console.log(`βœ“ Platform: ${platform}`);
27
27
 
28
- // Check if already installed globally
29
- try {
30
- const { execSync } = require('child_process');
31
- const npm = execSync('npm list -g hncode 2>&1').toString();
32
- if (npm.includes('hncode')) {
33
- console.log('ℹ️ hncode is already installed globally');
34
- console.log(' Run "npm uninstall -g hncode" to reinstall.\n');
35
- } else {
36
- console.log('βœ“ Not installed globally yet\n');
37
- }
38
- } catch (e) {
39
- console.log('βœ“ Not installed globally yet\n');
40
- }
41
-
42
28
  console.log('βœ… Environment check passed!\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "hncode β€” A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/agent-task.js CHANGED
@@ -76,7 +76,10 @@ function store(ctx) {
76
76
  */
77
77
  export function createTask(ctx, kind, options = {}) {
78
78
  const tasks = store(ctx);
79
- const prefix = kind === 'process' ? 'bash' : kind === 'agent' ? 'agent' : 'question';
79
+ // A teammate is its own kind rather than falling through to 'question': the id prefix is
80
+ // what a reader of a task list uses to tell at a glance what kind of work it is.
81
+ const PREFIX = { process: 'bash', agent: 'agent', teammate: 'mate' };
82
+ const prefix = PREFIX[kind] || 'question';
80
83
  const task = {
81
84
  taskId: newTaskId(prefix),
82
85
  kind,
@@ -101,10 +104,18 @@ export function createTask(ctx, kind, options = {}) {
101
104
  task.subagentType = options.subagentType;
102
105
  task.model = options.model;
103
106
  task.thinkingEffort = options.thinkingEffort;
107
+ } else if (kind === 'teammate') {
108
+ task.teamName = options.teamName;
109
+ task.memberName = options.memberName;
110
+ task.agentId = options.agentId;
104
111
  } else if (kind === 'question') {
105
112
  task.questionCount = options.questionCount || 0;
106
113
  task.toolCallId = options.toolCallId;
107
114
  }
115
+ // WHICH TASK THIS ONE BELONGS TO. A swarm's subagents are tasks of their own so /tasks can show
116
+ // what each is doing, and this is what groups them under the swarm that launched them. Absent
117
+ // for a top-level task, which is the common case.
118
+ if (options.parentTaskId) task.parentTaskId = String(options.parentTaskId);
108
119
  // Every task exposes its buffered output through `output`. The chunks are
109
120
  // stored raw and decoded lazily:
110
121
  // * a process appends Buffer chunks -> decode as UTF-8
package/src/agent.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // model answers without tool calls (or a step/interrupt condition is hit).
4
4
 
5
5
  import { LLM, setToolsList } from './llm.js';
6
- import { tools, getTool, llmTools } from './tools/index.js';
6
+ import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
7
7
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
8
  import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
9
9
  import { runHooks, runPatch, runPatchSync } from './plugin.js';
@@ -140,9 +140,10 @@ function hashKey(s) {
140
140
  h = (h * 0x01000193) >>> 0;
141
141
  }
142
142
  return h.toString(36) + '-' + str.length.toString(36);
143
- return h.toString(36) + '-' + str.length.toString(36);
144
143
  }
145
144
 
145
+ // Render what an Edit actually changed, for the MODEL's tool result.
146
+
146
147
  // Render what an Edit actually changed, for the MODEL's tool result.
147
148
  //
148
149
  // The UI has always received the edit through `editDiff` (see the tool_result
@@ -166,10 +167,18 @@ export function formatEditDiffForModel(diff, maxLines = 400) {
166
167
  const out = [];
167
168
  out.push('');
168
169
  out.push('--- diff ---');
170
+ // Unchanged lines above and below, unmarked, so the change can be LOCATED. Without them this
171
+ // was a quoted block with no indication of where in the file it landed β€” and identical-looking
172
+ // code appears in many places, so neither the model nor the user could confirm the edit hit
173
+ // the intended one rather than a same-text block further down.
174
+ const before = (diff.context && Array.isArray(diff.context.before)) ? diff.context.before : [];
175
+ const after = (diff.context && Array.isArray(diff.context.after)) ? diff.context.after : [];
176
+ for (const c of before) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
169
177
  let n = startLine;
170
178
  for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
171
179
  n = startLine;
172
180
  for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
181
+ for (const c of after) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
173
182
  // A whole-file edit (a Write-sized `new`) must not be echoed back in full.
174
183
  if (out.length > maxLines + 3) {
175
184
  const hidden = out.length - (maxLines + 3);
@@ -205,6 +214,37 @@ function toolResultLimitsEnabled() {
205
214
  return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
206
215
  }
207
216
 
217
+ /**
218
+ * A tool's return value, as the STRING the model can be sent.
219
+ *
220
+ * Every tool in `src/tools/` returns a plain string except the team tools, which return
221
+ * `{ content }` / `{ error }`. That object went into the `tool` message verbatim, and a provider
222
+ * that expects a string rejects the NEXT request with `missing type field in content item at
223
+ * index 0` β€” so a teammate's first `SendMessage` worked and then the conversation died, one
224
+ * message in. The symptom was a team that says hello and then never answers again.
225
+ *
226
+ * Returns `null` for the one shape that must stay an OBJECT: `ReadMediaFile` returns
227
+ * `{ text, media: [...] }`, and the media parts travel as structured blocks the caller builds
228
+ * from the object. Stringifying that would attach no image at all, so the caller keeps it.
229
+ *
230
+ * Normalised here, at the single point every tool result passes through, rather than in the
231
+ * team tools: a second tool family making the same choice cannot reintroduce the bug.
232
+ */
233
+ function toolResultText(value) {
234
+ if (typeof value === 'string') return value;
235
+ if (value == null) return '';
236
+ if (typeof value === 'object') {
237
+ // Leave a media result alone β€” the caller needs the parts, not a sentence about them.
238
+ if (Array.isArray(value.media)) return null;
239
+ if (typeof value.content === 'string') return value.content;
240
+ if (typeof value.error === 'string') return value.error;
241
+ if (typeof value.text === 'string') return value.text;
242
+ try { return JSON.stringify(value); } catch { return String(value); }
243
+ }
244
+ return String(value);
245
+ }
246
+
247
+
208
248
 
209
249
  export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
210
250
  You work inside a workspace directory using the tools provided.
@@ -280,9 +320,9 @@ export class Agent {
280
320
  // even if the filter would have allowed them. The catalog already excludes
281
321
  // them from every profile; this is the hard backstop that also covers the
282
322
  // `null` (= everything) case, so a subagent can never spawn another agent.
283
- const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
284
- ? tools.filter((t) => cfg.toolFilter.includes(t.name))
285
- : tools
323
+ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
324
+ ? combinedTools().filter((t) => cfg.toolFilter.includes(t.name))
325
+ : combinedTools()
286
326
  )
287
327
  .filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)))
288
328
  // AgentSwarm is gated behind swarm mode, like Agent is gated behind the user
@@ -420,9 +460,10 @@ export class Agent {
420
460
  const used = this.usedTokens();
421
461
  const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
422
462
  const msgs = this.messages || [];
423
- // Never cut inside the LEADING system block. `msgs[0]` is the assembled system
424
- // prompt, and canSplitAfter() would happily allow a split right after it β€” which
425
- // would drop the prompt itself and leave the request with no instructions.
463
+ // How far the LEADING system block runs. A cut before `floor` would land inside it,
464
+ // splitting the assembled system prompt across the drop boundary. The block itself is
465
+ // carried over by the trim (renamed `leadingSystem` there), because a cut can only
466
+ // ever land at or after its end.
426
467
  let floor = 0;
427
468
  while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
428
469
  let recent = 1;
@@ -435,13 +476,66 @@ export class Agent {
435
476
  // call. It is also the memoised form, so repeating this walk after a large tool
436
477
  // result is a WeakMap hit rather than a re-measure.
437
478
  size += messageTokens(msgs[split], null);
438
- if (split > floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
479
+ // The guard the leading system block needs, and the reverse of the `split > floor`
480
+ // this used to test. A cut at `split` drops `[0..split-1]`, so `split >= floor`
481
+ // keeps the cut at or after the block's end and never inside it β€” which is what
482
+ // the block's comment above asks for, and what `split > floor` inverted.
483
+ if (split >= floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
439
484
  if (size >= maxSize && bestSplit !== undefined) break;
440
485
  }
441
486
  return bestSplit ?? 0;
442
487
  }
443
488
 
489
+ /**
490
+ * Trim the history at a legal cut and append the summary + continuation note.
491
+ *
492
+ * Extracted from `run()` so it can be tested against the REAL implementation. A test that
493
+ * re-implemented these four lines was green against a duplicated system prompt β€” the copy
494
+ * carried its own guard, which is the failure mode of testing a restatement.
495
+ *
496
+ * Returns `{ split, dropped }`.
497
+ *
498
+ * Shape: `[ ...lead, ...kept, summary, continuation ]`.
499
+ *
500
+ * The summary is a USER message wrapped in a `<system-reminder>` (kimi's shape), not a
501
+ * `system` one. Two reasons:
502
+ * * the TUI rebuilds the system prompt from scratch each turn and SKIPS every `system`
503
+ * entry in the stored history, so a system summary was dropped before the model saw it;
504
+ * * the turn-end save filters `system` out of the session, so it was not persisted either
505
+ * β€” history trimmed AND summary lost at both ends, which made "compaction" pure data loss.
506
+ * The continuation note goes LAST so the model's next move is "keep going" rather than
507
+ * "answer the summary".
508
+ */
509
+ applyCompactionSplit(maxCtx, summary) {
510
+ const split = this.computeCompactSplit(maxCtx);
511
+ const keep = this.messages.slice(split);
512
+ const dropped = this.messages.length - keep.length;
513
+ // The LEADING system block is carried over explicitly. It sits at the head of the array
514
+ // and the cut drops everything before `split`, so it would otherwise be taken by every
515
+ // compaction β€” leaving the request with no instructions, which is the loss the `floor`
516
+ // guard in computeCompactSplit exists to prevent.
517
+ let leadingSystem = 0;
518
+ while (leadingSystem < this.messages.length
519
+ && this.messages[leadingSystem] && this.messages[leadingSystem].role === 'system') leadingSystem++;
520
+ // Only what the cut actually REMOVED. `keep` is `slice(split)`, so at `split === 0` it
521
+ // still contains the whole array including the block β€” prepending the block then sends the
522
+ // system prompt twice, and `llm.js` concatenates every system message into the request, so
523
+ // it would stay doubled in every compacted request from then on.
524
+ const lead = split > 0 ? this.messages.slice(0, leadingSystem) : [];
525
+ const summaryText = summary
526
+ ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
527
+ : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
528
+ this.messages = [
529
+ ...lead,
530
+ ...keep,
531
+ { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
532
+ { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
533
+ ];
534
+ return { split, dropped };
535
+ }
536
+
444
537
  async run() {
538
+ // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
445
539
  // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
446
540
  // the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
447
541
  // never suppress auto-compaction on the next message. (The constructor also
@@ -655,29 +749,7 @@ export class Agent {
655
749
  break;
656
750
  }
657
751
  // NOW trim, at a split point the boundary rule allows.
658
- const split = this.computeCompactSplit(maxCtx);
659
- const keep = this.messages.slice(split);
660
- const droppedCount = this.messages.length - keep.length;
661
- // Shape: [ ...kept, summary, continuation ].
662
- //
663
- // The summary is a USER message wrapped in a <system-reminder> (kimi's
664
- // shape), not a `system` message. Two reasons:
665
- // * the TUI rebuilds the system prompt from scratch each turn and SKIPS
666
- // every `system` entry in the stored history, so a system summary was
667
- // dropped before the model ever saw it;
668
- // * the turn-end save filters `system` out of the session, so it was not
669
- // persisted either β€” history trimmed AND summary lost at both ends,
670
- // which made "compaction" pure data loss.
671
- // The continuation note goes LAST so the model's next move is "keep going"
672
- // rather than "answer the summary".
673
- const summaryText = summary
674
- ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
675
- : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
676
- this.messages = [
677
- ...keep,
678
- { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
679
- { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
680
- ];
752
+ const { split, dropped: droppedCount } = this.applyCompactionSplit(maxCtx, summary);
681
753
  // The array was REPLACED, so every count the ledger held describes messages
682
754
  // that no longer exist. `sync` would notice on its own (the first/last
683
755
  // identities no longer match), but resetting here states the intent at the
@@ -702,8 +774,10 @@ export class Agent {
702
774
  // first, so it stays recoverable.
703
775
  //
704
776
  // Trigger: the request reached `trimThreshold` (50% by default) of the window.
705
- // Target: keep `trimKeepRatio` (30% by default) of the tool-result TEXT,
706
- // newest first. Skipped while a compaction just ran (that already shrank
777
+ // Target: keep `trimKeepRatio` (30% by default) of the tool-result TEXT, newest
778
+ // first. A different denominator from the trigger on purpose: the trigger asks
779
+ // about the window, this asks about the removable output. Skipped while a
780
+ // compaction just ran (that already shrank
707
781
  // things) and above the compaction trigger (compaction is the right tool by
708
782
  // then, and the two must not fight over the same history).
709
783
  const trimThreshold = Number.isFinite(this.cfg.trimThreshold) ? this.cfg.trimThreshold : DEFAULT_TRIM_THRESHOLD;
@@ -929,7 +1003,13 @@ export class Agent {
929
1003
  let _args = tc.args; let _skip = false;
930
1004
  try { const out = await runPatch('toolDispatch', { name: tc.name, args: tc.args, ctx: this.ctx }, (c) => c); if (out) { _args = out.args !== undefined ? out.args : tc.args; if (out.skip) _skip = true; } } catch (e) { console.error('[hncode-plugin] toolDispatch error:', e.message); }
931
1005
  if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
932
- else result = await tool.execute(_args, this.ctx);
1006
+ else {
1007
+ const raw = await tool.execute(_args, this.ctx);
1008
+ // `null` means the tool returned a structured MEDIA result: keep the object so the
1009
+ // image blocks can be built from it. Only a plain value is stringified.
1010
+ const text = toolResultText(raw);
1011
+ result = text === null ? raw : text;
1012
+ }
933
1013
  } catch (err) {
934
1014
  result = `Error running ${tc.name}: ${err.message}`;
935
1015
  ok = false;
@@ -126,6 +126,11 @@ export function renderCells(cells) {
126
126
  }
127
127
  out += c.ch;
128
128
  }
129
+ // Close the LAST run too. Without this the final style stayed open past the end of the
130
+ // row, and a caller that concatenates rows β€” `drawFrameBuffer` does, once per line β€”
131
+ // carried it into the next one: a card drawn over a dimmed or highlighted row tinted
132
+ // every line below it, and the row's own trailing SGR was simply lost.
133
+ if (cur !== null && cur !== '') out += RESET;
129
134
  return out;
130
135
  }
131
136
 
package/src/clipboard.js CHANGED
@@ -57,16 +57,34 @@ const ONESHOT_MS = 20000;
57
57
 
58
58
  function win() { return process.platform === 'win32'; }
59
59
 
60
- // ---------------------------------------------------------------- copy
60
+ /**
61
+ * Put text on the clipboard.
62
+ *
63
+ * WINDOWS: `Set-Clipboard` through PowerShell, NOT `clip.exe`.
64
+ *
65
+ * `clip.exe` takes UTF-16LE and needed a byte order mark to be read correctly β€” without it
66
+ * non-ASCII arrived as mojibake (`δΈ­ζ–‡ζ΅‹θ―•` came back as `-N噀Km諎`, measured). But the mark is then
67
+ * part of the clipboard text: every copy began with a zero-width U+FEFF that showed up as a stray
68
+ * invisible character wherever it was pasted. Measured over six samples, `Set-Clipboard` was better
69
+ * on every one:
70
+ *
71
+ * copied clip.exe + BOM Set-Clipboard
72
+ * hello U+FEFF hello hello
73
+ * δΈ­ζ–‡ζ΅‹θ―• U+FEFF δΈ­ζ–‡ζ΅‹θ―• δΈ­ζ–‡ζ΅‹θ―•
74
+ * line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
75
+ * emoji πŸŽ‰ ok U+FEFF emoji πŸŽ‰ ok emoji πŸŽ‰ ok
76
+ *
77
+ * It also needs no escaping: PowerShell receives a .NET string rather than a byte stream, so there
78
+ * is no console parser to satisfy β€” which is what the BOM and the quoting were working around.
79
+ *
80
+ * `clip.exe` stays as a fallback for a machine with no PowerShell at all, where a copy carrying a
81
+ * stray BOM beats no copy.
82
+ */
61
83
  export function copyText(text) {
62
84
  const s = String(text == null ? '' : text);
63
85
  if (!s) return false;
64
86
  if (win()) {
65
- // clip.exe takes UTF-16LE. (`cmd /c echo "…" | clip` mangled quotes, '%'
66
- // and newlines; plain UTF-8 input turned non-ASCII into mojibake.) Prefix the
67
- // UTF-16 byte order mark (FF FE) so clip.exe is never left to guess the endian
68
- // or treat input as ANSI β€” without it, non-ASCII on some Windows versions /
69
- // code pages came out mojibake.
87
+ if (setClipboardText(s)) return true;
70
88
  cp.execSync('clip', { input: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]), windowsHide: true, timeout: 5000 });
71
89
  return true;
72
90
  }
@@ -75,6 +93,24 @@ export function copyText(text) {
75
93
  return true;
76
94
  }
77
95
 
96
+ /** `Set-Clipboard` via PowerShell. False when no PowerShell can be run. */
97
+ function setClipboardText(s) {
98
+ // The text travels as base64 so nothing in it can end the script or be re-parsed. A quoted
99
+ // PowerShell literal would need every `'` doubled, and a newline or a backtick in the text would
100
+ // otherwise change what the script means.
101
+ const b64 = Buffer.from(s, 'utf16le').toString('base64');
102
+ const full = 'Add-Type -AssemblyName System.Windows.Forms; '
103
+ + '[System.Windows.Forms.Clipboard]::SetText('
104
+ + `[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('${b64}')))`;
105
+ for (const exe of [_psExe, 'powershell']) {
106
+ try {
107
+ cp.execFileSync(exe, ['-NoProfile', '-NonInteractive', '-Command', full], { timeout: 10000, windowsHide: true });
108
+ return true;
109
+ } catch { /* try the next exe */ }
110
+ }
111
+ return false;
112
+ }
113
+
78
114
  // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
79
115
  // `powershell` (5.1) β€” measured 1.9s vs 11.5s for the same image probe β€” and the image
80
116
  // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is