@bahulam/code 2.6.1 → 2.6.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.
@@ -180,29 +180,29 @@ async function main() {
180
180
  \x1b[1m\x1b[36mBahulam Code\x1b[0m — Bahulam's coding agent — bahulam.ai
181
181
 
182
182
  \x1b[1mUsage:\x1b[0m
183
- bahulam-code Start interactive REPL
184
- bahulam-code "instruction" Run a single instruction
185
- bahulam-code --headless -p "x" Non-interactive: auto-approve, JSONL output
186
- bahulam-code --headless -p "x" --vision screenshot.png
183
+ bahulam Start interactive REPL
184
+ bahulam "instruction" Run a single instruction
185
+ bahulam --headless -p "x" Non-interactive: auto-approve, JSONL output
186
+ bahulam --headless -p "x" --vision screenshot.png
187
187
  Attach an image via the vision analysis pipeline
188
- bahulam-code --resume Resume last conversation
189
- bahulam-code dashboard Open analytics dashboard
190
- bahulam-code login Sign in via browser
191
- bahulam-code logout Sign out and clear credentials
192
- bahulam-code init Scaffold .bahulam config, memory, hooks, tasks
193
- bahulam-code version Show version
188
+ bahulam --resume Resume last conversation
189
+ bahulam dashboard Open analytics dashboard
190
+ bahulam login Sign in via browser
191
+ bahulam logout Sign out and clear credentials
192
+ bahulam init Scaffold .bahulam config, memory, hooks, tasks
193
+ bahulam version Show version
194
194
 
195
195
  \x1b[1mAnalytics:\x1b[0m
196
- bahulam-code sessions List recent local sessions
197
- bahulam-code stats Show aggregate local session stats
198
- bahulam-code history Show recent prompt history
196
+ bahulam sessions List recent local sessions
197
+ bahulam stats Show aggregate local session stats
198
+ bahulam history Show recent prompt history
199
199
 
200
200
  \x1b[1mSkills:\x1b[0m
201
- bahulam-code skills list [--all|--project]
202
- bahulam-code skills view <name> [resource]
203
- bahulam-code skills install <path-or-git-url> [--project] [--force]
204
- bahulam-code skills update <name> [--project]
205
- bahulam-code skills remove <name> [--project]
201
+ bahulam skills list [--all|--project]
202
+ bahulam skills view <name> [resource]
203
+ bahulam skills install <path-or-git-url> [--project] [--force]
204
+ bahulam skills update <name> [--project]
205
+ bahulam skills remove <name> [--project]
206
206
 
207
207
  \x1b[1mREPL Commands:\x1b[0m
208
208
  /help Show available commands
@@ -239,7 +239,7 @@ async function main() {
239
239
  Max reconnect window for dropped streams
240
240
  KEPLER_BLOCK_SEPARATOR Tool/content separator: space, dotted, or off
241
241
 
242
- \x1b[2mDocs: https://0xb0.ai\x1b[0m
242
+ \x1b[2mDocs: https://bahulam.ai\x1b[0m
243
243
  `);
244
244
  return;
245
245
  }
@@ -10,9 +10,18 @@
10
10
  const EXPLORE_TOOL_CATEGORY = new Map([
11
11
  ['read_file', 'read'], ['read', 'read'], ['read_files', 'read'],
12
12
  ['read_batch', 'read'], ['get_file_info', 'read'],
13
+ // analyze_code is the "cheap 10x-lighter than read_file" tool the system
14
+ // prompt tells the agent to prefer for structure lookups. Burst usage is
15
+ // as common as read bursts, so classify it as a read for collapse.
16
+ ['analyze_code', 'read'],
13
17
  ['list_files', 'list'], ['glob', 'list'], ['ls', 'list'],
14
18
  ['search_code', 'search'], ['search_files', 'search'], ['grep', 'search'],
19
+ // validate_* tools are read-only structure/build checks the agent chains
20
+ // during post-write verification. They fit naturally in a search-ish bucket
21
+ // ("checking") rather than opening a discrete card per call.
22
+ ['validate_file', 'search'], ['validate_structure', 'search'],
15
23
  ['index_project', 'index'], ['register_project', 'index'],
24
+ ['get_project_overview', 'index'],
16
25
  ]);
17
26
 
18
27
  export function exploreCollapseEnabled() {
@@ -19,6 +19,7 @@ import * as readline from 'node:readline';
19
19
  import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { execSync as _execSync } from 'node:child_process';
22
+ import { Writable as _WritableStream } from 'node:stream';
22
23
  import { c, progressBar, spinner, inPlace, renderMarkdown, renderDiff, formatElapsed, formatCost, stripAnsi } from './ansi.mjs';
23
24
  import { calculateCost, formatCostValue, formatTokens, costToCredits, formatCredits } from '../core/pricing.mjs';
24
25
  import { TarangStreamClient, EVENT_TYPES } from '../core/stream-client.mjs';
@@ -56,9 +57,13 @@ import { appendTask, ensureTaskFiles, loadTaskBoard, moveTask, removeTask, taskC
56
57
  import { applyCompactSummary, localCompactSummary, parseCompactTailCount, prepareCompactHistory } from '../core/compact-history.mjs';
57
58
  import {
58
59
  appendVisionAnalysisToInstruction,
60
+ appendDocumentsToInstruction,
59
61
  attachmentSummaryLine,
62
+ documentSummaryLine,
60
63
  prepareImageAttachments,
64
+ prepareDocumentAttachments,
61
65
  publicAttachmentMetadata,
66
+ publicDocumentMetadata,
62
67
  resolveAttachmentPath,
63
68
  writeClipboardImageToTemp,
64
69
  } from '../core/attachments.mjs';
@@ -1259,6 +1264,30 @@ function renderEvent(event) {
1259
1264
  }
1260
1265
  break;
1261
1266
 
1267
+ // Live steering ack events (PRD-081 §5.2). renderEvent is called from
1268
+ // the SSE loop (see `for await ... jsonlWriter.writeKeplerEvent(event)`
1269
+ // in the /execute path) so every event already lands in the transcript.
1270
+ // Here we only render the user-visible surface — no direct jsonlWriter
1271
+ // calls (it's out of scope in this top-level dispatcher anyway).
1272
+ case 'user_intervention_accepted': {
1273
+ // Endpoint response already told the CLI "sent to running agent".
1274
+ // The SSE echo here is durable-replay backup; nothing to render.
1275
+ break;
1276
+ }
1277
+ case 'user_intervention_delivered': {
1278
+ renderBlockBoundary('status', { compactSame: true });
1279
+ const tool = data?.delivered_at_tool ? ` ${c.dim(`(via ${data.delivered_at_tool})`)}` : '';
1280
+ process.stderr.write(` ${c.green('✓')} ${c.dim('follow-up delivered to agent')}${tool}\n`);
1281
+ runtime.lastRenderedBlock = 'status';
1282
+ break;
1283
+ }
1284
+ case 'user_intervention_queued': {
1285
+ // Task ended before delivery; endpoint returned queued_next_turn to
1286
+ // the submission call and the CLI already rendered that ack. Nothing
1287
+ // more to show; the outer SSE loop persists this event to JSONL.
1288
+ break;
1289
+ }
1290
+
1262
1291
  case 'complete': {
1263
1292
  stopSpinner();
1264
1293
  flushContent();
@@ -2924,9 +2953,28 @@ export async function startTerminalRepl() {
2924
2953
  return 'type any extra context (paths, corrections, follow-ups) · [Enter] send · [Esc] cancel · [Ctrl+P] pause';
2925
2954
  }
2926
2955
 
2956
+ // Proxy stream: swallows writes when the dock owns the input row so
2957
+ // readline's echoes and _refreshLine cursor moves can't fight the dock's
2958
+ // absolute cursor placement (PRD-081 §5.1 — cursor race). When the dock
2959
+ // is not mounted (plain-mode fallback, KEPLER_FIXED_INPUT=0, non-TTY),
2960
+ // writes pass through so history navigation / backspace still redraw.
2961
+ const readlineOutputProxy = new _WritableStream({
2962
+ write(chunk, _enc, cb) {
2963
+ if (!isInputDockMounted()) {
2964
+ try { process.stderr.write(chunk); } catch {}
2965
+ }
2966
+ cb();
2967
+ },
2968
+ });
2969
+ // Preserve enough of stderr's TTY surface so readline still treats us as
2970
+ // a terminal (raw mode, keypress events, history navigation).
2971
+ readlineOutputProxy.isTTY = process.stderr.isTTY;
2972
+ readlineOutputProxy.columns = process.stderr.columns;
2973
+ readlineOutputProxy.rows = process.stderr.rows;
2974
+
2927
2975
  const rl = readline.createInterface({
2928
2976
  input: process.stdin,
2929
- output: process.stderr,
2977
+ output: readlineOutputProxy,
2930
2978
  prompt: userPrompt(),
2931
2979
  completer: (line) => {
2932
2980
  if (line.startsWith('/')) {
@@ -2973,6 +3021,14 @@ export async function startTerminalRepl() {
2973
3021
  }
2974
3022
 
2975
3023
  function restoreReadlineCursor() {
3024
+ // When the dock owns the input row, delegate to it so INPUT_INDENT +
3025
+ // meta/tips row math stays authoritative. Falling back to
3026
+ // readline.cursorTo(col) here lands INPUT_INDENT chars too far left
3027
+ // and yields the "/-shifts-cursor" bug (PRD-081 §5.1 cursor race).
3028
+ if (isInputDockMounted()) {
3029
+ focusDockInput(userPrompt(), rl.line || '', typeof rl.cursor === 'number' ? rl.cursor : null);
3030
+ return;
3031
+ }
2976
3032
  const col = Math.max(0, promptColumns() + Number(rl.cursor || 0));
2977
3033
  readline.cursorTo(process.stderr, col);
2978
3034
  }
@@ -3080,10 +3136,15 @@ export async function startTerminalRepl() {
3080
3136
 
3081
3137
  function renderIdleDockInput() {
3082
3138
  if (!isInputDockMounted()) return false;
3139
+ // rl.cursor is readline's byte offset within rl.line. Threading it
3140
+ // through to focusDockInput makes arrow-key navigation visually move
3141
+ // the terminal cursor within the buffer instead of always landing at
3142
+ // the end of the string.
3083
3143
  return renderDockInput(userPrompt(), rl.line || '', {
3084
3144
  context: buildContextStrip(),
3085
3145
  meta: buildDockMeta(),
3086
3146
  tips: idleInputTips(),
3147
+ cursor: typeof rl.cursor === 'number' ? rl.cursor : null,
3087
3148
  });
3088
3149
  }
3089
3150
 
@@ -3278,8 +3339,28 @@ export async function startTerminalRepl() {
3278
3339
  }
3279
3340
 
3280
3341
  try {
3342
+ // ── Document attachments (client-side, PRD-091 shape 1) ──
3343
+ // Extract text locally (PDF via pdf-parse, others as UTF-8). We stage
3344
+ // the docs here and inline their text into the user turn LAST, after
3345
+ // vision/image processing, because the image parser normalizes
3346
+ // whitespace and would collapse the doc block's newlines.
3347
+ const docPrep = await prepareDocumentAttachments(originalInput, { cwd: safeCwd() });
3348
+ if (docPrep.documents.length) {
3349
+ process.stderr.write(
3350
+ ` ${c.brand('◇')} ${c.dim(`attached ${docPrep.documents.length} document${docPrep.documents.length === 1 ? '' : 's'}:`)} ` +
3351
+ `${docPrep.documents.map(documentSummaryLine).join(c.dim(' · '))}\n`
3352
+ );
3353
+ jsonlWriter.writeKeplerEvent({
3354
+ type: 'attachments',
3355
+ data: { documents: docPrep.documents.map(publicDocumentMetadata) },
3356
+ });
3357
+ }
3358
+ // Image parser operates on the doc-stripped instruction so @doc.pdf
3359
+ // refs are already gone by the time it sees the text.
3360
+ const imageSourceInput = docPrep.instruction || originalInput;
3361
+
3281
3362
  const pending = pendingVisionPaths(ctx);
3282
- const prepared = prepareImageAttachments(originalInput, {
3363
+ const prepared = prepareImageAttachments(imageSourceInput, {
3283
3364
  cwd: safeCwd(),
3284
3365
  extraPaths: pending,
3285
3366
  });
@@ -3315,6 +3396,11 @@ export async function startTerminalRepl() {
3315
3396
  } else {
3316
3397
  input = prepared.instruction || originalInput;
3317
3398
  }
3399
+ // Inline documents AFTER image/vision handling so their newlines
3400
+ // survive the image parser's whitespace normalization.
3401
+ if (docPrep.documents.length) {
3402
+ input = appendDocumentsToInstruction(input, docPrep.documents);
3403
+ }
3318
3404
  } catch (err) {
3319
3405
  process.stderr.write(` ${c.red('Vision error: ' + (err.message || String(err)))}\n`);
3320
3406
  showPrompt();
@@ -3431,16 +3517,41 @@ export async function startTerminalRepl() {
3431
3517
  process.stderr.write('\n');
3432
3518
  }
3433
3519
  executionInputVisible = false;
3434
- try {
3435
- await client.resume(instruction);
3436
- jsonlWriter.writeKeplerEvent({
3437
- type: 'user_intervention',
3438
- data: { instruction, task_id: client.currentTaskId || null },
3439
- });
3440
- process.stderr.write(` ${c.green('↳')} ${c.dim('sent follow-up to running agent')}\n`);
3441
- } catch {
3520
+ // Live steering (PRD-081 §5.2): submit through the dedicated
3521
+ // /api/intervention/{task_id} path, not /resume. The stream client
3522
+ // returns a status object so we render the true backend decision
3523
+ // (accepted vs queued-for-next-turn vs duplicate) instead of guessing.
3524
+ const result = await client.sendIntervention(instruction);
3525
+ const taskId = client.currentTaskId || null;
3526
+ const status = result && result.status;
3527
+ const interventionId = result && result.interventionId;
3528
+
3529
+ // Persist the local record regardless of outcome so the transcript
3530
+ // reflects what the user typed. Delivered/queued follow-ups will
3531
+ // get their SSE ack events written separately by the event handler.
3532
+ jsonlWriter.writeKeplerEvent({
3533
+ type: 'user_intervention',
3534
+ data: {
3535
+ instruction,
3536
+ task_id: taskId,
3537
+ intervention_id: interventionId || null,
3538
+ status: status || 'unknown',
3539
+ },
3540
+ });
3541
+
3542
+ if (status === 'accepted') {
3543
+ process.stderr.write(` ${c.green('↳')} ${c.dim('sent to running agent')}\n`);
3544
+ } else if (status === 'duplicate') {
3545
+ process.stderr.write(` ${c.dim('↳ already sent (idempotent)')}\n`);
3546
+ } else if (status === 'queued_next_turn') {
3547
+ _queuedLines.push(instruction);
3548
+ process.stderr.write(` ${c.yellow('↳')} ${c.dim('task ended — queued for next turn')}\n`);
3549
+ } else {
3550
+ // no_task, error, or unknown — fall back to next-turn queue so the
3551
+ // user's text is never silently lost.
3442
3552
  _queuedLines.push(instruction);
3443
- process.stderr.write(` ${c.yellow('↳')} ${c.dim('queued follow-up for the next turn')}\n`);
3553
+ const errBits = result && result.error ? ` ${c.dim(`(${String(result.error).slice(0, 80)})`)}` : '';
3554
+ process.stderr.write(` ${c.yellow('↳')} ${c.dim('queued for next turn')}${errBits}\n`);
3444
3555
  }
3445
3556
  }
3446
3557
 
@@ -29,7 +29,7 @@ export async function runSkillsCommand(args, { cwd = process.cwd() } = {}) {
29
29
  }
30
30
  if (action === 'view') {
31
31
  const name = rest.find(arg => !arg.startsWith('--'));
32
- if (!name) throw new Error('Usage: bahulam-code skills view <name> [resource-path]');
32
+ if (!name) throw new Error('Usage: bahulam skills view <name> [resource-path]');
33
33
  const nameIndex = rest.indexOf(name);
34
34
  const resource = rest.slice(nameIndex + 1).find(arg => !arg.startsWith('--')) || null;
35
35
  print(loader.view(name, resource));
@@ -55,6 +55,52 @@ const IGNORED_DIRS = new Set([
55
55
  'build', 'dist', 'node_modules', 'venv',
56
56
  ]);
57
57
 
58
+ // Files or directories whose presence at the root implies this IS a project.
59
+ // One is enough. Kept broad so we accept Node/Python/Rust/Go/Ruby/Java/C++
60
+ // projects, container-only repos, and Bahulam/agent-configured directories.
61
+ const PROJECT_MARKERS = [
62
+ '.git', '.hg', '.svn',
63
+ '.bahulam', '.kepler', // Bahulam project state
64
+ 'package.json', // Node
65
+ 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile',
66
+ 'Cargo.toml', // Rust
67
+ 'go.mod', // Go
68
+ 'Gemfile', // Ruby
69
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', // Java/Kotlin
70
+ 'Makefile', 'CMakeLists.txt', // C/C++
71
+ 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
72
+ 'AGENTS.md', 'CLAUDE.md', 'KEPLER.md', // Agent config lives at root
73
+ '.editorconfig', // Broad but a strong "this is a repo" signal
74
+ ];
75
+
76
+ // System / user-home roots we refuse outright — indexing these would sweep
77
+ // every project the user has ever touched and produce noise, not signal.
78
+ // Compared per-realpath so symlinks don't sneak past.
79
+ function _dangerousRootSet() {
80
+ const set = new Set([
81
+ '/', '/tmp', '/var', '/etc', '/usr', '/opt',
82
+ '/Applications', '/Library', '/System',
83
+ '/Users', '/home', '/root',
84
+ '/Volumes', '/mnt', '/media',
85
+ ]);
86
+ try { set.add(os.homedir()); } catch {}
87
+ try { set.add(path.parse(os.homedir()).root); } catch {}
88
+ return set;
89
+ }
90
+
91
+ function isDangerousRoot(root) {
92
+ return _dangerousRootSet().has(root);
93
+ }
94
+
95
+ function hasProjectMarkers(root) {
96
+ for (const marker of PROJECT_MARKERS) {
97
+ try {
98
+ if (fs.existsSync(path.join(root, marker))) return true;
99
+ } catch { /* skip unreadable entries */ }
100
+ }
101
+ return false;
102
+ }
103
+
58
104
  function projectId(canonicalPath) {
59
105
  return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12);
60
106
  }
@@ -372,7 +418,7 @@ export class ProjectRegistry {
372
418
  return resource;
373
419
  }
374
420
 
375
- async register(rawPath, { forceRefresh = false, force_refresh = false } = {}) {
421
+ async register(rawPath, { forceRefresh = false, force_refresh = false, bypassProjectMarkers = false } = {}) {
376
422
  if (!rawPath) {
377
423
  throw new Error('get_project_overview requires a project path');
378
424
  }
@@ -403,9 +449,25 @@ export class ProjectRegistry {
403
449
  if (!fs.statSync(root).isDirectory()) {
404
450
  throw new Error(`Project path is not a directory: ${root}`);
405
451
  }
406
- if (root === path.parse(root).root || root === os.homedir()) {
452
+ if (isDangerousRoot(root)) {
453
+ throw new Error(
454
+ `Refusing to index ${root} — too broad or system-level. ` +
455
+ `Pass a specific project directory, or answer the user's question ` +
456
+ `without get_project_overview if it doesn't need project files.`
457
+ );
458
+ }
459
+ // Programmatic file-read registration (registerFileRead) bypasses the
460
+ // marker check — the user has a specific file in hand and we index its
461
+ // parent so tool guards don't block the read. The user-facing
462
+ // get_project_overview tool DOES enforce the check.
463
+ if (!bypassProjectMarkers && !hasProjectMarkers(root)) {
407
464
  throw new Error(
408
- `Refusing to index ${root} too broad. Pass the project directory itself.`
465
+ `No project markers found at ${root} (checked for .git, package.json, ` +
466
+ `pyproject.toml, Cargo.toml, go.mod, Gemfile, pom.xml, Makefile, ` +
467
+ `Dockerfile, AGENTS.md, .bahulam/). If the user's request does not ` +
468
+ `require this codebase, do NOT call get_project_overview again for ` +
469
+ `this session — answer the question directly. If it does require code, ` +
470
+ `ask the user to point at the correct project directory.`
409
471
  );
410
472
  }
411
473
 
@@ -504,7 +566,7 @@ export class ProjectRegistry {
504
566
  const dir = path.dirname(filePath);
505
567
  if (dir === path.parse(dir).root || dir === os.homedir()) return null;
506
568
 
507
- const registered = await this.register(dir);
569
+ const registered = await this.register(dir, { bypassProjectMarkers: true });
508
570
  const owner = this.projects.get(registered.resource.project_id);
509
571
  if (!owner) return null;
510
572
 
package/src/ui/banner.mjs CHANGED
@@ -60,11 +60,11 @@ export function printBanner(version = '') {
60
60
  const vTag = version ? `${dim(' — ')}${dim('v' + version)}` : '';
61
61
 
62
62
  write('\n');
63
- write(` ${orbit} ${dim('· orbit')}\n`);
63
+ write(` ${orbit} ${dim('· bahulam — abundance')}\n`);
64
64
  write('\n');
65
65
  write(`${wordmarkFrame()}\n`);
66
66
  write('\n');
67
- write(` ${dim('the coding agent')}\n`);
67
+ write(` ${dim('abundance, in your terminal')}\n`);
68
68
  write(` ${dev}${vTag}\n`);
69
69
  write('\n');
70
70
  }
@@ -139,7 +139,7 @@ export function printStyledConfig(creds) {
139
139
 
140
140
  const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
141
141
 
142
- write(`\n${paint.bold('Bahulam Code Configuration')} ${dim('(~/.bahulam/config.json)')}\n`);
142
+ write(`\n${paint.bold('Bahulam Code · Abundance')} ${dim('(~/.bahulam/config.json)')}\n`);
143
143
  write(`${dim('─'.repeat(50))}\n`);
144
144
  write(` Token: ${mask(creds.token)}\n`);
145
145
  write(` OpenRouter: ${mask(creds.openRouterKey)}\n`);
@@ -151,7 +151,7 @@ export function printStyledConfig(creds) {
151
151
  }
152
152
 
153
153
  export function printGoodbye() {
154
- write(`\n${paint.bold(paint.brand.primary('Goodbye!'))}\n\n`);
154
+ write(`\n${paint.bold(paint.brand.primary('until next time — abundance awaits'))}\n\n`);
155
155
  }
156
156
 
157
157
  // ── Git probe ────────────────────────────────────────────────────────────
@@ -204,7 +204,7 @@ export function getLoginSuccessHTML() {
204
204
  }
205
205
  .dev { color: #3fb950; }
206
206
  .bahulam { color: #58a6ff; }
207
- .bahulam { color: #7c3aed; letter-spacing: 4px; }
207
+ .bahulam { color: #06b6d4; letter-spacing: 4px; }
208
208
  .check {
209
209
  font-size: 64px;
210
210
  color: #3fb950;
@@ -64,7 +64,12 @@ let inputRowsMax = DEFAULT_MAX_INPUT_ROWS;
64
64
  let inputRows = MIN_INPUT_ROWS;
65
65
  let reservedRows = FIXED_ROWS + MIN_INPUT_ROWS;
66
66
  let unsubResize = null;
67
- let lastFrame = { context: '', meta: '', tips: '' };
67
+ // prefix + value are tracked here so any code path that redraws the dock
68
+ // (renderFrame, applyLayout, redrawDockFrame, resize) can end by parking
69
+ // the cursor at the correct input position. Without this, readline echoes
70
+ // land on rows the dock briefly moved through mid-render and characters
71
+ // appear above/below the input row.
72
+ let lastFrame = { context: '', meta: '', tips: '', prefix: '', value: '', cursor: null };
68
73
  let resetting = false;
69
74
 
70
75
  function write(s) { try { OUT.write(s); } catch {} }
@@ -123,9 +128,26 @@ function computeInputRowsForBuffer(prefix, value) {
123
128
  // Resize the input area to a new row count. Moves the scroll region so
124
129
  // content above shifts accordingly; the dock frame is redrawn at the
125
130
  // new position. No-op if the count didn't change.
131
+ //
132
+ // CRITICAL: when the dock SHRINKS (inputRows decreases), the frame moves
133
+ // down and previously-dock rows become part of the scroll region. Those
134
+ // rows still hold their old dock content (rule chars, prior input text,
135
+ // meta line) which then leaks into the transcript as streamed content
136
+ // scrolls past them. We clear the old dock region BEFORE moving the frame
137
+ // so the freed rows are blank when they enter the scroll region.
126
138
  function setInputRowsTo(nextRows) {
127
139
  const clamped = Math.max(MIN_INPUT_ROWS, Math.min(inputRowsMax, Math.floor(nextRows)));
128
140
  if (clamped === inputRows) return false;
141
+ const shrinking = clamped < inputRows;
142
+ if (mounted && shrinking) {
143
+ // Clear the entire old reserved region — top rule through safety row —
144
+ // so nothing that lived here leaks into the scroll region after we
145
+ // move the frame down.
146
+ for (let row = topRuleRow(); row <= rows(); row++) {
147
+ moveTo(row, 1);
148
+ clearLine();
149
+ }
150
+ }
129
151
  inputRows = clamped;
130
152
  reservedRows = FIXED_ROWS + inputRows;
131
153
  if (mounted) {
@@ -189,18 +211,40 @@ function bottomRuleLine() {
189
211
  return paint.brand.primary(ruleChars(Math.max(0, cols() - 1)));
190
212
  }
191
213
 
214
+ // Always park the cursor at the tracked (prefix, value) input position.
215
+ // Called at the END of every dock render path so readline echoes land in
216
+ // the input row, not on whichever row a mid-render moveTo left them on.
217
+ // When there is no tracked input (nothing to focus yet), park at the
218
+ // scroll-region bottom so writes at least stay above the dock frame.
219
+ function parkCursorAtInput() {
220
+ if (!mounted) return;
221
+ const prefix = lastFrame.prefix || '';
222
+ const value = lastFrame.value || '';
223
+ if (!prefix && !value) {
224
+ moveTo(inputRowStart(), INPUT_INDENT + 1);
225
+ return;
226
+ }
227
+ focusDockInput(prefix, value, lastFrame.cursor);
228
+ }
229
+
192
230
  function applyLayout() {
193
231
  if (!mounted) return;
194
232
  const bottom = contentBottomRow();
195
233
  setScrollRegion(1, bottom);
196
234
  renderFrame(lastFrame);
197
- moveTo(bottom, 1);
235
+ // On (re)mount and resize, park at input if we have one; otherwise sit at
236
+ // the bottom of the content region so any pending content writes flush
237
+ // above the dock rather than into a stale mid-frame position.
238
+ if (lastFrame.prefix || lastFrame.value) {
239
+ parkCursorAtInput();
240
+ } else {
241
+ moveTo(bottom, 1);
242
+ }
198
243
  }
199
244
 
200
245
  function renderFrame(frame = {}) {
201
246
  if (!mounted) return;
202
247
  lastFrame = { ...lastFrame, ...frame };
203
- saveCursor();
204
248
 
205
249
  moveTo(topRuleRow(), 1);
206
250
  clearLine();
@@ -220,7 +264,7 @@ function renderFrame(frame = {}) {
220
264
  clearLine();
221
265
  write(padLine(bottomRuleLine()));
222
266
 
223
- // Meta row: model · cwd ⎇ branch · turn N · tokens · elapsed
267
+ // Meta row: cwd ⎇ branch · turn N · tokens
224
268
  moveTo(metaRow(), 1);
225
269
  clearLine();
226
270
  const metaIndent = ' '.repeat(META_INDENT);
@@ -238,16 +282,19 @@ function renderFrame(frame = {}) {
238
282
  // Safety row.
239
283
  moveTo(rows(), 1);
240
284
  clearLine();
241
- restoreCursor();
285
+
286
+ // Deliberately NOT using saveCursor/restoreCursor. VT100 has a single
287
+ // cursor-save slot per terminal; nested calls in drawInputLines /
288
+ // pinned-status writers clobber the outer save and restore to the wrong
289
+ // place. Instead, callers that need the cursor parked at the input row
290
+ // invoke parkCursorAtInput() after renderFrame returns.
242
291
  }
243
292
 
244
293
  function clearInputRows() {
245
- saveCursor();
246
294
  for (let row = inputRowStart(); row <= inputRowEnd(); row++) {
247
295
  moveTo(row, 1);
248
296
  clearLine();
249
297
  }
250
- restoreCursor();
251
298
  }
252
299
 
253
300
  export function clearDockArea({ restore = true } = {}) {
@@ -281,7 +328,6 @@ function layoutInput(prefix, value) {
281
328
  }
282
329
 
283
330
  function drawInputLines(lines) {
284
- saveCursor();
285
331
  const indent = ' '.repeat(INPUT_INDENT);
286
332
  for (let i = 0; i < inputRows; i++) {
287
333
  const row = inputRowStart() + i;
@@ -291,7 +337,7 @@ function drawInputLines(lines) {
291
337
  write(`${indent}${lines[i]}`);
292
338
  }
293
339
  }
294
- restoreCursor();
340
+ // No save/restore — caller parks cursor via focusDockInput.
295
341
  }
296
342
 
297
343
  export function isInputDockMounted() {
@@ -378,6 +424,7 @@ export function clearPinnedStatus() {
378
424
  export function redrawDockFrame() {
379
425
  if (!mounted) return false;
380
426
  renderFrame(lastFrame);
427
+ parkCursorAtInput();
381
428
  return true;
382
429
  }
383
430
 
@@ -385,7 +432,7 @@ export function prepareInputPrompt({ context = '', tips = '', meta = '' } = {})
385
432
  if (!mounted) return false;
386
433
  setInputRowsTo(MIN_INPUT_ROWS);
387
434
  clearInputRows();
388
- renderFrame({ context, tips, meta });
435
+ renderFrame({ context, tips, meta, prefix: '', value: '' });
389
436
  moveTo(inputRowStart(), INPUT_INDENT + 1);
390
437
  return true;
391
438
  }
@@ -393,29 +440,40 @@ export function prepareInputPrompt({ context = '', tips = '', meta = '' } = {})
393
440
  export function clearInputPrompt() {
394
441
  if (!mounted) return false;
395
442
  clearInputRows();
443
+ lastFrame.value = '';
396
444
  renderFrame(lastFrame);
445
+ parkCursorAtInput();
397
446
  return true;
398
447
  }
399
448
 
400
- export function renderDockInput(prefix, value, { context = '', tips = '', meta = '' } = {}) {
449
+ export function renderDockInput(prefix, value, { context = '', tips = '', meta = '', cursor = null } = {}) {
401
450
  if (!mounted) return false;
402
451
  setInputRowsTo(computeInputRowsForBuffer(prefix, value));
403
- renderFrame({ context, tips, meta });
452
+ renderFrame({ context, tips, meta, prefix, value, cursor });
404
453
  const layout = layoutInput(prefix, value);
405
454
  drawInputLines(layout.lines);
406
- focusDockInput(prefix, value);
455
+ focusDockInput(prefix, value, cursor);
407
456
  return true;
408
457
  }
409
458
 
410
459
  /**
411
- * Move the terminal cursor to the position that corresponds to the logical
412
- * end of `prefix + value` within the (possibly wrapped and truncated) input
413
- * area. If the buffer overflowed, the cursor lands on the last visible row.
460
+ * Move the terminal cursor to the position that corresponds to
461
+ * `prefix + value[0..cursorInValue]` within the (possibly wrapped and
462
+ * truncated) input area. When `cursorInValue` is null/undefined, the cursor
463
+ * lands at the logical end of `value` (append-mode default).
464
+ *
465
+ * `cursorInValue` is a char index into the RAW value string (matching
466
+ * readline's `rl.cursor`), not into the wrapped/rendered output.
414
467
  */
415
- export function focusDockInput(prefix, value = '') {
468
+ export function focusDockInput(prefix, value = '', cursorInValue = null) {
416
469
  if (!mounted) return false;
417
470
  const layout = layoutInput(prefix, value);
418
- const offset = visibleWidth(`${prefix || ''}${value || ''}`);
471
+ const valueStr = String(value || '');
472
+ const rawCursor = cursorInValue == null
473
+ ? valueStr.length
474
+ : Math.max(0, Math.min(valueStr.length, Math.floor(cursorInValue)));
475
+ const cursorSlice = valueStr.slice(0, rawCursor);
476
+ const offset = visibleWidth(`${prefix || ''}${cursorSlice}`);
419
477
  const pos = cursorPositionInLines(layout.wrapped, offset);
420
478
  const visibleRowIdx = Math.max(
421
479
  0,
@@ -19,7 +19,7 @@
19
19
  * none → identity (input returned unchanged)
20
20
  *
21
21
  * Brand identity (Mission Control PRD-055):
22
- * primary Deep Space Purple #7c3aed
22
+ * primary Abundance Cyan #06b6d4
23
23
  * accent Stellar Magenta #ec4899
24
24
  * data Neon Cyan #22d3ee
25
25
  * success Aligned green #22c55e
@@ -40,7 +40,7 @@ const RESET = `${ESC}0m`;
40
40
 
41
41
  export const TOKENS = Object.freeze({
42
42
  // Brand
43
- 'brand.primary': { rgb: [124, 58, 237], ansi256: 99, ansi16: 'magenta' }, // #7c3aed
43
+ 'brand.primary': { rgb: [6, 182, 212], ansi256: 44, ansi16: 'cyan' }, // #06b6d4
44
44
  'brand.accent': { rgb: [236, 72, 153], ansi256: 198, ansi16: 'magenta' }, // #ec4899
45
45
  'brand.data': { rgb: [34, 211, 238], ansi256: 87, ansi16: 'cyan' }, // #22d3ee
46
46