@bahulam/code 0.1.13 → 0.1.14

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.
@@ -24,20 +24,24 @@ import * as path from 'node:path';
24
24
  import { COMPOSED_TOOL_SEPARATOR } from '../pi-compose.mjs';
25
25
 
26
26
  /**
27
- * Derive a pack slug from a pi package name.
28
- * pi-web-access web-access-studio
29
- * pi-redmine → redmine-studio
30
- * @ffmpeg/transitions transitions-studio
31
- * plain-name → plain-name-studio
27
+ * Derive a pack slug from a source package name. No forced suffix — a
28
+ * pack can be anything (studio, analyzer, connector, worker, …), and
29
+ * pinning a semantic to the slug guesses wrong most of the time. The
30
+ * default is the source name, sanitized. Author overrides with --slug.
31
+ *
32
+ * pi-web-access → pi-web-access
33
+ * pi-redmine → pi-redmine
34
+ * @ffmpeg/transitions → transitions (scope stripped)
35
+ * filesystem-mcp → filesystem-mcp
36
+ * plain-name → plain-name
32
37
  */
33
38
  export function deriveSlug(packageName) {
34
39
  let base = String(packageName || '').trim();
35
40
  const scoped = base.match(/^@[^/]+\/(.+)$/);
36
41
  if (scoped) base = scoped[1];
37
- base = base.replace(/^pi-/, '');
38
42
  base = base.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
39
- if (!base) base = 'pi-pack';
40
- return `${base}-studio`;
43
+ if (!base) base = 'pack';
44
+ return base;
41
45
  }
42
46
 
43
47
  /**
@@ -77,13 +81,75 @@ function truncate(s, n) {
77
81
  return str.slice(0, n - 1) + '…';
78
82
  }
79
83
 
84
+ /**
85
+ * Compose the "Requirements & constraints" block from the analyzer's
86
+ * findings. Injected into the generated agent's system prompt so the
87
+ * sub-agent knows what its composed tools need — no user teaching
88
+ * required. Falls back to an empty list if requirements is absent (fresh
89
+ * install where the analyzer didn't run, etc.).
90
+ */
91
+ function requirementsPromptLines(requirements, namespace) {
92
+ if (!requirements) return [];
93
+ const lines = ['', 'Requirements & constraints (from ingredient analysis):'];
94
+ const bins = requirements.system_binaries || [];
95
+ if (bins.length) {
96
+ const names = bins.map(b => b.name).join(', ');
97
+ lines.push(
98
+ `- System binaries required: ${names}. If a tool errors with "ENOENT" or "spawn ${bins[0].name}", tell the user to install them (macOS: \`${bins[0].install_hints?.darwin || 'via brew'}\`; Linux: \`${bins[0].install_hints?.linux || 'via package manager'}\`).`,
99
+ );
100
+ }
101
+ const creds = (requirements.env_vars || []).filter(v => v.credential);
102
+ if (creds.length) {
103
+ lines.push(
104
+ `- API keys / credentials expected: ${creds.map(v => v.name).join(', ')}. If a tool fails with an auth error, ask the user to set the missing env var.`,
105
+ );
106
+ }
107
+ if (requirements.workspace_scoped_paths) {
108
+ lines.push(
109
+ '- Paths passed to composed tools MUST be workspace-relative (relative to the current working directory). Absolute paths outside cwd are rejected with "Path is outside the workspace". If the user references an absolute path, ask them to `cd` closer to it or copy the file into the workspace.',
110
+ );
111
+ }
112
+ // Per-tool schema constraints the agent must respect. Emit BOTH required
113
+ // fields AND regex/range constraints — the underlying pi tools throw
114
+ // opaque path/type errors when a required param is missing, and the
115
+ // agent's default reasoning tends to skip params whose descriptions
116
+ // sound "optional" even when the schema marks them required.
117
+ const tc = requirements.tool_constraints || {};
118
+ const toolsWithConstraints = Object.keys(tc).filter(t => Object.keys(tc[t]).length);
119
+ if (toolsWithConstraints.length) {
120
+ lines.push('- Strict input schemas — supply EVERY required field and respect all constraints. Missing a required field usually throws an opaque error like `paths[1] argument must be of type string`:');
121
+ for (const t of toolsWithConstraints) {
122
+ const params = tc[t];
123
+ const required = Object.keys(params).filter(p => params[p].required);
124
+ const regexed = Object.entries(params).filter(([, c]) => c.regex);
125
+ const ranged = Object.entries(params).filter(([, c]) => c.min != null || c.max != null || c.enum);
126
+ if (required.length) {
127
+ lines.push(` - \`${namespace}${COMPOSED_TOOL_SEPARATOR}${t}\` requires: ${required.map(p => `\`${p}\``).join(', ')}`);
128
+ }
129
+ for (const [param, c] of regexed) {
130
+ lines.push(` · \`${param}\` must match \`${c.regex}\``);
131
+ }
132
+ for (const [param, c] of ranged) {
133
+ const parts = [];
134
+ if (c.min != null) parts.push(`min ${c.min}`);
135
+ if (c.max != null) parts.push(`max ${c.max}`);
136
+ if (c.enum) parts.push(`one of ${JSON.stringify(c.enum)}`);
137
+ lines.push(` · \`${param}\` ${parts.join(', ')}`);
138
+ }
139
+ }
140
+ }
141
+ // If we detected no external requirements at all, keep the block out so
142
+ // the prompt stays clean.
143
+ return lines.length > 1 ? lines : [];
144
+ }
145
+
80
146
  /**
81
147
  * Compose an agent system prompt from the pi package + its tools.
82
148
  * Focused on WHAT the agent should do, not step-by-step recipes — the
83
149
  * generic template can't know the pack's domain. Users are expected to
84
150
  * edit the prompt after generation.
85
151
  */
86
- function generatePrompt(packageName, namespace, toolNames, hasState) {
152
+ function generatePrompt(packageName, namespace, toolNames, hasState, requirements = null) {
87
153
  const composed = toolNames.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`);
88
154
  const stateLines = hasState
89
155
  ? [
@@ -103,6 +169,7 @@ function generatePrompt(packageName, namespace, toolNames, hasState) {
103
169
  'Available composed tools:',
104
170
  ...composed.map(t => `- \`${t}\``),
105
171
  ...stateLines,
172
+ ...requirementsPromptLines(requirements, namespace),
106
173
  '',
107
174
  'Rules:',
108
175
  '- Use the composed tools directly — do not describe what you would do, DO it.',
@@ -453,7 +520,16 @@ export function scaffoldPiPack({
453
520
  `Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
454
521
  240,
455
522
  );
456
- const systemPrompt = generatePrompt(packageName, namespace, toolNames, state);
523
+
524
+ // Pull the requirements sidecar the analyzer wrote at install time
525
+ // (may be absent if user is scaffolding manually with an older ingredient).
526
+ let requirements = null;
527
+ const reqSidecar = path.join(piDir, '.bahulam-requirements.json');
528
+ if (fs.existsSync(reqSidecar)) {
529
+ try { requirements = JSON.parse(fs.readFileSync(reqSidecar, 'utf-8')); } catch { /* skip */ }
530
+ }
531
+
532
+ const systemPrompt = generatePrompt(packageName, namespace, toolNames, state, requirements);
457
533
 
458
534
  const manifest = renderManifest({
459
535
  slug,
@@ -130,5 +130,33 @@ export function createPiShim({ pluginName = 'pi', captured }) {
130
130
  },
131
131
  };
132
132
 
133
- return pi;
133
+ // Pi's ExtensionAPI is a moving target — packages call methods we haven't
134
+ // stubbed yet (registerMessageRenderer, registerRoute, registerHandler,
135
+ // …). Any unstubbed method call throws, aborting activation before
136
+ // registerTool ever runs, and the probe reports 0 tools.
137
+ //
138
+ // Fall back to a no-op returner for every unknown property so activation
139
+ // reaches its full extent. A tool's runtime call may still fail if it
140
+ // needed that surface — that's an accurate signal at execution time,
141
+ // not a silent black hole at load time.
142
+ return new Proxy(pi, {
143
+ get(target, prop, receiver) {
144
+ if (prop in target) return Reflect.get(target, prop, receiver);
145
+ if (typeof prop === 'symbol') return undefined;
146
+ if (process.env.DEBUG) {
147
+ process.stderr.write(`[pi:${pluginName}] shim: pi.${String(prop)} stubbed (no-op)\n`);
148
+ }
149
+ // Return a callable that also has method access (e.g. pi.foo.bar).
150
+ // Property access on the stub returns another stub, so chains never
151
+ // throw. Result is undefined so anything that reads a return value
152
+ // treats it as "not present" (typeof result === 'undefined').
153
+ const stub = function stub() { return undefined; };
154
+ return new Proxy(stub, {
155
+ get(t, p) {
156
+ if (typeof p === 'symbol') return t[p];
157
+ return stub;
158
+ },
159
+ });
160
+ },
161
+ });
134
162
  }
@@ -39,6 +39,8 @@ export const RESERVED_TOOL_NAMES = new Set([
39
39
  // write
40
40
  'write_file', 'write_project', 'edit_file', 'delete_file', 'shell',
41
41
  'analyze_image', 'generate_image',
42
+ // background jobs (PRD-102 §6.2.3) — long-running renders, builds, etc.
43
+ 'job_output', 'job_kill', 'job_status', 'job_list',
42
44
  // agent/skill/workflow admin
43
45
  'ask_user', 'agent_create', 'agent_sync', 'agents_list',
44
46
  'skill_install', 'skill_update', 'skill_remove', 'skill_view', 'skills_list',
@@ -27,6 +27,7 @@ const PLUGIN_MANAGEMENT_COMMANDS = new Set([
27
27
  'validate', 'check', 'lint',
28
28
  'list', 'ls', 'remove', 'rm', 'uninstall',
29
29
  'enable', 'disable', 'info', 'update', 'upgrade',
30
+ 'doctor',
30
31
  ]);
31
32
 
32
33
  function parsePluginArgs(argv) {
@@ -69,7 +70,7 @@ function parsePluginArgs(argv) {
69
70
  if (arg && (arg.includes('/') || fs.existsSync?.(arg))) parsed.source = arg;
70
71
  else parsed.pluginName = arg;
71
72
  }
72
- else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade'].includes(parsed.action)) {
73
+ else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade', 'doctor'].includes(parsed.action)) {
73
74
  parsed.pluginName = positional.shift() || null;
74
75
  }
75
76
  } else {
@@ -184,6 +184,7 @@ import {
184
184
  moveToContent,
185
185
  prepareInputPrompt,
186
186
  redrawDockFrame,
187
+ redrawDockInput,
187
188
  renderDockInput,
188
189
  unmountInputDock,
189
190
  } from '../ui/input-dock.mjs';
@@ -1559,11 +1560,35 @@ function subAgentRunId(data = {}) {
1559
1560
  return data?.run_id || data?.sub_agent_run_id || null;
1560
1561
  }
1561
1562
 
1563
+ function activeSubAgentRunsMap() {
1564
+ const runs = session.activeSubAgentRuns;
1565
+ if (runs instanceof Map) return runs;
1566
+
1567
+ const restored = new Map();
1568
+ if (Array.isArray(runs)) {
1569
+ for (const item of runs) {
1570
+ if (Array.isArray(item) && item.length >= 2) {
1571
+ restored.set(item[0], item[1]);
1572
+ } else if (item && typeof item === 'object') {
1573
+ const runId = item.runId || item.run_id || item.id;
1574
+ if (runId) restored.set(runId, item);
1575
+ }
1576
+ }
1577
+ } else if (runs && typeof runs === 'object') {
1578
+ for (const [key, value] of Object.entries(runs)) {
1579
+ if (value && typeof value === 'object') restored.set(key, value);
1580
+ }
1581
+ }
1582
+
1583
+ session.activeSubAgentRuns = restored;
1584
+ return restored;
1585
+ }
1586
+
1562
1587
  function normalizeSubAgentRunData(data = {}) {
1563
1588
  if (!data || typeof data !== 'object') return data;
1564
1589
  const runId = subAgentRunId(data);
1565
1590
  if (!runId) return data;
1566
- const laneRun = session.activeSubAgentRuns?.get(runId);
1591
+ const laneRun = activeSubAgentRunsMap().get(runId);
1567
1592
  const patch = {};
1568
1593
  if (!data.run_id) patch.run_id = runId;
1569
1594
  if (laneRun?.type && !data.sub_agent) patch.sub_agent = laneRun.type;
@@ -1591,7 +1616,7 @@ function ensureFoldedSubAgentTools(agentType, key = agentType, data = {}) {
1591
1616
  let fold = runtime.foldedSubAgentToolMap.get(key);
1592
1617
  if (!fold) {
1593
1618
  const runId = subAgentRunId(data);
1594
- const laneRun = runId ? session.activeSubAgentRuns?.get(runId) : null;
1619
+ const laneRun = runId ? activeSubAgentRunsMap().get(runId) : null;
1595
1620
  fold = {
1596
1621
  key,
1597
1622
  runId,
@@ -1604,7 +1629,7 @@ function ensureFoldedSubAgentTools(agentType, key = agentType, data = {}) {
1604
1629
  runtime.foldedSubAgentToolMap.set(key, fold);
1605
1630
  } else {
1606
1631
  const runId = subAgentRunId(data);
1607
- const laneRun = runId ? session.activeSubAgentRuns?.get(runId) : null;
1632
+ const laneRun = runId ? activeSubAgentRunsMap().get(runId) : null;
1608
1633
  if (runId && !fold.runId) fold.runId = runId;
1609
1634
  if (laneRun) fold.label = subAgentLaneLabel(laneRun);
1610
1635
  if (laneRun?.query && !fold.query) fold.query = laneRun.query;
@@ -1614,8 +1639,8 @@ function ensureFoldedSubAgentTools(agentType, key = agentType, data = {}) {
1614
1639
  }
1615
1640
 
1616
1641
  function createSubAgentLane(agentType, query, runId, data = {}) {
1617
- session.activeSubAgentRuns = session.activeSubAgentRuns || new Map();
1618
- const sameTypeOrdinals = [...session.activeSubAgentRuns.values()]
1642
+ const activeRuns = activeSubAgentRunsMap();
1643
+ const sameTypeOrdinals = [...activeRuns.values()]
1619
1644
  .filter(run => run.type === agentType)
1620
1645
  .map(run => Number(run.ordinal || 1));
1621
1646
  const ordinal = sameTypeOrdinals.length ? Math.max(...sameTypeOrdinals) + 1 : 1;
@@ -1631,16 +1656,14 @@ function createSubAgentLane(agentType, query, runId, data = {}) {
1631
1656
  // the close line agree even for the first run of a batch.
1632
1657
  forceOrdinal: Number(data?.parallel_batch) > 1,
1633
1658
  };
1634
- session.activeSubAgentRuns.set(runId, lane);
1659
+ activeRuns.set(runId, lane);
1635
1660
  ensureFoldedSubAgentTools(agentType, runId, { type: agentType, query, run_id: runId });
1636
1661
  _syncSubAgentWindow();
1637
1662
  return lane;
1638
1663
  }
1639
1664
 
1640
1665
  function activeSubAgentLanes() {
1641
- return session.activeSubAgentRuns instanceof Map
1642
- ? [...session.activeSubAgentRuns.values()]
1643
- : [];
1666
+ return [...activeSubAgentRunsMap().values()];
1644
1667
  }
1645
1668
 
1646
1669
  function subAgentLaneLabel(lane, lanes = activeSubAgentLanes()) {
@@ -2322,8 +2345,8 @@ function renderEvent(event) {
2322
2345
  // concurrent runs of the same type are only distinguishable by the
2323
2346
  // backend-issued run_id; label lanes explore#1 / explore#2 when
2324
2347
  // more than one run is active. Solo runs render exactly as before.
2325
- session.activeSubAgentRuns = session.activeSubAgentRuns || new Map();
2326
- const hadActiveRuns = session.activeSubAgentRuns.size > 0;
2348
+ const activeRuns = activeSubAgentRunsMap();
2349
+ const hadActiveRuns = activeRuns.size > 0;
2327
2350
  if (!hadActiveRuns) {
2328
2351
  stopSpinner();
2329
2352
  clearPendingHead();
@@ -2331,7 +2354,8 @@ function renderEvent(event) {
2331
2354
  }
2332
2355
  const runId = data?.run_id || `${agentType}:${Date.now().toString(36)}`;
2333
2356
  const lane = createSubAgentLane(agentType, query, runId, data);
2334
- const parallel = session.activeSubAgentRuns.size > 1 || lane.forceOrdinal;
2357
+ const activeRunsAfterStart = activeSubAgentRunsMap();
2358
+ const parallel = activeRunsAfterStart.size > 1 || lane.forceOrdinal;
2335
2359
  const label = subAgentLaneLabel(lane);
2336
2360
  renderBlockBoundary('subagent');
2337
2361
  process.stderr.write(renderSubAgentOpen({ id: runId, type: label, query, parentDepth: parallel ? 0 : undefined }).replace(/^\n/, '') + '\n');
@@ -2342,7 +2366,7 @@ function renderEvent(event) {
2342
2366
  // inner tool calls stream here instead of flooding the transcript.
2343
2367
  setSubAgentWindowActive(true);
2344
2368
  if (hadActiveRuns) {
2345
- updateSpinner(`${session.activeSubAgentRuns.size} agents running`);
2369
+ updateSpinner(`${activeRunsAfterStart.size} agents running`);
2346
2370
  } else {
2347
2371
  // Phase per sub-agent run: the status line counts elapsed time and
2348
2372
  // tool calls live ("plan agent · 4 calls · 32s") for the whole run.
@@ -2367,16 +2391,17 @@ function renderEvent(event) {
2367
2391
  if (!eventRunId && hasParallelSubAgentRuns()) {
2368
2392
  break;
2369
2393
  }
2370
- const laneRun = session.activeSubAgentRuns?.get(eventRunId);
2394
+ const laneRun = activeSubAgentRunsMap().get(eventRunId);
2371
2395
  if (laneRun) laneRun.tools++;
2372
2396
  foldSubAgentToolProgress(eventData);
2373
- const laneParallel = (session.activeSubAgentRuns?.size || 0) > 1;
2397
+ const activeRuns = activeSubAgentRunsMap();
2398
+ const laneParallel = activeRuns.size > 1;
2374
2399
  if (laneParallel) {
2375
2400
  bumpSpinnerProgress();
2376
2401
  const activeLanes = activeSubAgentLanes();
2377
2402
  const lanes = activeLanes
2378
2403
  .map(r => `${subAgentLaneLabel(r, activeLanes)} ${r.tools}`).join(' · ');
2379
- updateSpinner(`${session.activeSubAgentRuns.size} agents · ${lanes}`);
2404
+ updateSpinner(`${activeRuns.size} agents · ${lanes}`);
2380
2405
  break;
2381
2406
  }
2382
2407
  // Feed the live window from THIS event — it always fires (55/55 in
@@ -2433,16 +2458,17 @@ function renderEvent(event) {
2433
2458
  // Retire this run's display lane; while sibling runs are still
2434
2459
  // active keep the shared window/spinner alive for them.
2435
2460
  const eventRunId = subAgentRunId(eventData);
2436
- const doneRun = session.activeSubAgentRuns?.get(eventRunId);
2461
+ const activeRuns = activeSubAgentRunsMap();
2462
+ const doneRun = activeRuns.get(eventRunId);
2437
2463
  const doneLabel = doneRun ? subAgentLaneLabel(doneRun) : agentType;
2438
2464
  const doneFold = eventRunId ? removeFoldedSubAgentTools(eventRunId) : null;
2439
2465
  if (doneFold) {
2440
2466
  doneFold.agentType = doneLabel;
2441
2467
  doneFold.label = doneLabel;
2442
2468
  }
2443
- if (eventRunId) session.activeSubAgentRuns?.delete(eventRunId);
2444
- else session.activeSubAgentRuns?.clear();
2445
- const siblingsActive = (session.activeSubAgentRuns?.size || 0) > 0;
2469
+ if (eventRunId) activeRuns.delete(eventRunId);
2470
+ else activeRuns.clear();
2471
+ const siblingsActive = activeRuns.size > 0;
2446
2472
  // In verbose mode each sub-agent tool already rendered a full
2447
2473
  // transcript card live — flushing the fold would list every tool a
2448
2474
  // second time. The fold batch is the durable record ONLY when tools
@@ -2733,17 +2759,18 @@ function renderEvent(event) {
2733
2759
  // late events land correctly instead of corrupting fresh state —
2734
2760
  // but only for ONE turn boundary: if they're still around at the
2735
2761
  // next complete with no closure, force-clean to avoid stale lanes.
2736
- const lanesLive = (session.activeSubAgentRuns?.size || 0) > 0;
2762
+ const activeRuns = activeSubAgentRunsMap();
2763
+ const lanesLive = activeRuns.size > 0;
2737
2764
  if (lanesLive && !session._lanesPreservedAtTurnEnd) {
2738
2765
  session._lanesPreservedAtTurnEnd = true;
2739
- const n = session.activeSubAgentRuns.size;
2766
+ const n = activeRuns.size;
2740
2767
  process.stderr.write(` ${c.dim(`${n} agent run${n === 1 ? '' : 's'} still active in background — progress continues below`)}\n`);
2741
2768
  } else {
2742
2769
  session._lanesPreservedAtTurnEnd = false;
2743
2770
  if (showSubAgentTools(getVerbosity())) resetFoldedSubAgentTools();
2744
2771
  else flushFoldedSubAgentTools();
2745
2772
  resetSubAgents();
2746
- session.activeSubAgentRuns?.clear();
2773
+ activeRuns.clear();
2747
2774
  setSubAgentWindowActive(false);
2748
2775
  }
2749
2776
  session.inSubAgent = false;
@@ -4298,11 +4325,13 @@ export async function startTerminalRepl() {
4298
4325
  options: Array.isArray(req?.options) ? req.options : [],
4299
4326
  context: req?.context || '',
4300
4327
  });
4328
+ if (isInputDockMounted()) redrawDockInput();
4301
4329
  if (res?.answer) {
4302
4330
  process.stderr.write(` ${c.green('✓')} ${c.dim('answered:')} ${c.brand(res.answer)}\n`);
4303
4331
  } else {
4304
4332
  process.stderr.write(` ${c.dim('Question declined — agent proceeds with its own judgment.')}\n`);
4305
4333
  }
4334
+ if (isInputDockMounted()) redrawDockInput();
4306
4335
  return res;
4307
4336
  };
4308
4337
 
@@ -518,6 +518,21 @@ function drawInputLines(lines) {
518
518
  // No save/restore — caller parks cursor via focusDockInput.
519
519
  }
520
520
 
521
+ function terminalCellWidthFromColumn(text, startColumn = 1) {
522
+ let col = Math.max(1, Math.floor(Number(startColumn) || 1));
523
+ const start = col;
524
+ for (const ch of queue.stripSequences(String(text ?? ''))) {
525
+ const cp = ch.codePointAt(0);
526
+ if (cp === 0x09) {
527
+ col = (Math.floor((col - 1) / 8) + 1) * 8 + 1;
528
+ continue;
529
+ }
530
+ if (cp === 0x0a || cp === 0x0d) continue;
531
+ col += queue.cellWidth(ch);
532
+ }
533
+ return Math.max(0, col - start);
534
+ }
535
+
521
536
  export function isInputDockMounted() {
522
537
  return mounted;
523
538
  }
@@ -650,6 +665,20 @@ export function redrawDockFrame() {
650
665
  return true;
651
666
  }
652
667
 
668
+ export function redrawDockInput() {
669
+ if (!mounted) return false;
670
+ contentTrackingActive = false;
671
+ renderFrame(lastFrame);
672
+ if (Array.isArray(lastFrame.overlayLines)) {
673
+ drawInputLines(lastFrame.overlayLines);
674
+ } else {
675
+ const layout = layoutInput(lastFrame.prefix, lastFrame.value);
676
+ drawInputLines(layout.lines);
677
+ }
678
+ parkCursorAtInput();
679
+ return true;
680
+ }
681
+
653
682
  export function prepareInputPrompt({ context = '', tips = '', meta = '' } = {}) {
654
683
  if (!mounted) return false;
655
684
  contentTrackingActive = false;
@@ -725,28 +754,35 @@ export function renderDockOverlay({
725
754
  export function focusDockInput(prefix, value = '', cursorInValue = null) {
726
755
  if (!mounted) return false;
727
756
  contentTrackingActive = false;
757
+ const target = cursorTargetForInput(prefix, value, cursorInValue);
758
+ if (queue.isActive()) {
759
+ // Record the park position — every queue op re-parks here so readline
760
+ // echoes always land in the input row, even mid-stream.
761
+ queue.park(target.row, target.col);
762
+ return true;
763
+ }
764
+ moveTo(target.row, target.col);
765
+ return true;
766
+ }
767
+
768
+ function cursorTargetForInput(prefix, value = '', cursorInValue = null) {
728
769
  const layout = layoutInput(prefix, value);
729
770
  const valueStr = String(value || '');
730
771
  const rawCursor = cursorInValue == null
731
772
  ? valueStr.length
732
773
  : Math.max(0, Math.min(valueStr.length, Math.floor(cursorInValue)));
733
774
  const cursorSlice = valueStr.slice(0, rawCursor);
734
- const offset = visibleWidth(`${prefix || ''}${cursorSlice}`);
735
- const pos = cursorPositionInLines(layout.wrapped, offset);
775
+ const inputColumn = INPUT_INDENT + 1;
776
+ const measureInputLine = (line) => terminalCellWidthFromColumn(line, inputColumn);
777
+ const offset = terminalCellWidthFromColumn(`${prefix || ''}${cursorSlice}`, inputColumn);
778
+ const pos = cursorPositionInLines(layout.wrapped, offset, measureInputLine);
736
779
  const visibleRowIdx = Math.max(
737
780
  0,
738
781
  Math.min(inputRows - 1, pos.row - Math.max(0, layout.wrapped.length - inputRows)),
739
782
  );
740
783
  const row = inputRowStart() + visibleRowIdx;
741
784
  const col = Math.min(cols(), INPUT_INDENT + 1 + Math.max(0, pos.col));
742
- if (queue.isActive()) {
743
- // Record the park position — every queue op re-parks here so readline
744
- // echoes always land in the input row, even mid-stream.
745
- queue.park(row, col);
746
- return true;
747
- }
748
- moveTo(row, col);
749
- return true;
785
+ return { row, col };
750
786
  }
751
787
 
752
788
  export function inputRowColumn() {
@@ -766,6 +802,8 @@ export function _internals() {
766
802
  drawableColumns,
767
803
  resetContentCursor,
768
804
  contentCursor: () => ({ row: contentCursorRow, col: contentCursorCol, active: contentTrackingActive }),
805
+ cursorTargetForInput,
806
+ terminalCellWidthFromColumn,
769
807
  overlayRowsForWrapped,
770
808
  FIXED_ROWS,
771
809
  MAX_INPUT_ROWS_CAP,
@@ -76,19 +76,20 @@ export function tailWithEllipsis(lines, maxRows, ellipsis = '… ') {
76
76
  *
77
77
  * Used to place the terminal cursor after rendering the input buffer.
78
78
  */
79
- export function cursorPositionInLines(visibleLines, offset) {
79
+ export function cursorPositionInLines(visibleLines, offset, measure = visibleWidth) {
80
80
  const arr = Array.isArray(visibleLines) ? visibleLines : [];
81
+ const widthOf = typeof measure === 'function' ? measure : visibleWidth;
81
82
  let remaining = Math.max(0, Math.floor(offset));
82
83
  for (let row = 0; row < arr.length; row++) {
83
84
  const line = arr[row] || '';
84
- const w = visibleWidth(line);
85
+ const w = widthOf(line);
85
86
  if (remaining <= w) return { row, col: remaining };
86
87
  remaining -= w;
87
88
  // Newline between wrapped lines doesn't count as a visible column,
88
89
  // but consumes zero of the remaining offset either.
89
90
  }
90
91
  const lastRow = Math.max(0, arr.length - 1);
91
- return { row: lastRow, col: visibleWidth(arr[lastRow] || '') };
92
+ return { row: lastRow, col: widthOf(arr[lastRow] || '') };
92
93
  }
93
94
 
94
95
  // ── internals ────────────────────────────────────────────────────────────