@bahulam/code 2.6.14 → 2.6.16

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.
@@ -29,6 +29,7 @@ import { createToolExecutor } from '../core/tool-executor.mjs';
29
29
  import { buildWorkScope, promptProjectRoots } from '../core/work-scope.mjs';
30
30
  import { CheckpointManager } from '../core/checkpoints.mjs';
31
31
  import { HookRunner } from '../config/hook-runner.mjs';
32
+ import { readShippedCatalog } from '../config/model-catalog.mjs';
32
33
  import { runPreflight } from '../onboarding/preflight.mjs';
33
34
  import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
34
35
  import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
@@ -42,6 +43,7 @@ import {
42
43
  import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
43
44
  import { TarangAuth } from '../auth/tarang-auth.mjs';
44
45
  import { ApprovalManager } from '../core/approval.mjs';
46
+ import * as telemetry from '../telemetry/index.mjs';
45
47
  import { resolveBackendUrl } from '../core/backend-url.mjs';
46
48
  import { formatMessageWindow, lowWindowStatus, messagesRemaining } from '../core/rate-limit-display.mjs';
47
49
  import { formatAgentErrorGuidance } from '../core/error-guidance.mjs';
@@ -49,6 +51,7 @@ import { BUILTIN_AGENTS, findBuiltinAgent, localAgentMatches, runAgent, runAgent
49
51
  import { createAgentFile, isVsCodeTerminal, listLocalAgents, openAgentFile, syncAgentsToBackend } from '../agents/scaffold.mjs';
50
52
  import { SessionManager } from '../core/session-manager.mjs';
51
53
  import { parseArgs } from '../config/cli-args.mjs';
54
+ import { pickModelOverridesForm } from './repl-model-form.mjs';
52
55
  import { loadEffectivePolicy, formatPolicySourceRows } from '../core/policy-resolver.mjs';
53
56
  import { loadProjectContext } from '../core/project-context-loader.mjs';
54
57
  import { buildContextEnvelope } from '../core/context-envelope.mjs';
@@ -72,8 +75,10 @@ import { toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
72
75
  import { exploreCategory, exploreCollapseEnabled, isExploreTool } from './repl-explore.mjs';
73
76
  import { session, orbitRef, sessionMgrRef, runtime } from './repl-state.mjs';
74
77
  import { safeCwd } from './repl-utils.mjs';
78
+ import * as rqueue from '../ui/render-queue.mjs';
75
79
  import {
76
80
  appendContent,
81
+ bumpSpinnerProgress,
77
82
  clearPendingHead,
78
83
  clippedThinking,
79
84
  expandIndex,
@@ -82,14 +87,18 @@ import {
82
87
  flushExploreRun,
83
88
  flushPendingHead,
84
89
  isInlineOutcomeTool,
90
+ pushSubAgentWindowLine,
85
91
  renderBlockBoundary,
86
92
  renderExploreRun,
93
+ renderFileDiffEvent,
87
94
  renderStagnation,
88
95
  renderToolCall,
89
96
  renderToolResult,
97
+ setSubAgentWindowActive,
90
98
  startContentStream,
91
99
  startSpinner,
92
100
  stopSpinner,
101
+ transcriptRenderableLines,
93
102
  thinkingPrefix,
94
103
  updateSpinner,
95
104
  } from './repl-render.mjs';
@@ -319,16 +328,211 @@ function sessionModelOverrideEntries() {
319
328
  }
320
329
 
321
330
  function printModelCommandUsage() {
322
- process.stderr.write(` ${c.gray('Usage:')} /model [model]\n`);
331
+ process.stderr.write(` ${c.gray('Usage:')} /model interactive per-role form\n`);
332
+ process.stderr.write(` /model <model> set coding model directly\n`);
323
333
  process.stderr.write(` /model <role> <model>\n`);
324
- process.stderr.write(` /model clear [role]\n`);
334
+ process.stderr.write(` /model ${NAMED_MODEL_MODES_LIST.join('|')}\n`);
335
+ process.stderr.write(` /model list · status · refresh · clear [role]\n`);
325
336
  process.stderr.write(` ${c.gray('Roles:')} ${MODEL_ROLE_ORDER.map(role => MODEL_ROLE_LABELS[role]).join(', ')}\n`);
326
337
  }
327
338
 
339
+ // ── W7: curated platform catalog (PRD-076) ──
340
+ // Platform-route model overrides are validated against the backend's
341
+ // curated catalog (harness_validated models only). BYOK route skips
342
+ // validation entirely — the user's key, the user's models. Fail-open:
343
+ // if the catalog can't be fetched, the backend remains the enforcer.
344
+
345
+ const NAMED_MODEL_MODES_LIST = ['fast', 'thinking', 'extra', 'max'];
346
+ const NAMED_MODEL_MODES = new Set(NAMED_MODEL_MODES_LIST);
347
+
348
+ let _modelCatalogCache = null;
349
+ let _modelCatalogError = null;
350
+ let _modelCatalogSource = null; // 'snapshot' | 'backend'
351
+ let _backendRefreshInFlight = null;
352
+
353
+ async function fetchModelCatalog(ctx) {
354
+ // Seed from the shipped snapshot so /model list, /model form and the
355
+ // curation warnings work offline. The backend refresh below overlays
356
+ // any drift without blocking the UI.
357
+ if (!_modelCatalogCache) {
358
+ const shipped = readShippedCatalog();
359
+ if (shipped && shipped.length) {
360
+ _modelCatalogCache = shipped;
361
+ _modelCatalogSource = 'snapshot';
362
+ _modelCatalogError = null;
363
+ }
364
+ }
365
+
366
+ // Kick off a background backend refresh at most once per REPL session.
367
+ // Snapshot already served the caller — this only matters for the *next*
368
+ // /model list. Skip entirely when offline mode is forced.
369
+ if (!_backendRefreshInFlight && _modelCatalogSource !== 'backend' &&
370
+ process.env.BAHULAM_MODEL_CATALOG_OFFLINE !== '1') {
371
+ _backendRefreshInFlight = refreshCatalogFromBackend(ctx).finally(() => {
372
+ _backendRefreshInFlight = null;
373
+ });
374
+ }
375
+
376
+ if (_modelCatalogCache) return _modelCatalogCache;
377
+
378
+ // Snapshot missing (shouldn't happen in a shipped package) — fall back
379
+ // to whatever the backend refresh produces.
380
+ await _backendRefreshInFlight;
381
+ return _modelCatalogCache;
382
+ }
383
+
384
+ async function refreshCatalogFromBackend(ctx) {
385
+ try {
386
+ const creds = ctx?.auth?.loadCredentials?.();
387
+ if (!creds?.backendUrl) {
388
+ if (!_modelCatalogCache) _modelCatalogError = 'not logged in (no backend url)';
389
+ return;
390
+ }
391
+ const headers = { 'X-Product': 'bahulam' };
392
+ if (creds.token) headers['Authorization'] = `Bearer ${creds.token}`;
393
+ const resp = await fetch(`${creds.backendUrl}/api/models`, {
394
+ headers,
395
+ signal: AbortSignal.timeout(3000),
396
+ });
397
+ if (!resp.ok) {
398
+ if (!_modelCatalogCache) _modelCatalogError = `backend returned ${resp.status}`;
399
+ return;
400
+ }
401
+ const data = await resp.json();
402
+ const models = Array.isArray(data?.models) ? data.models : null;
403
+ if (models && models.length) {
404
+ _modelCatalogCache = models;
405
+ _modelCatalogSource = 'backend';
406
+ _modelCatalogError = null;
407
+ } else if (!_modelCatalogCache) {
408
+ _modelCatalogError = models ? 'catalog is empty' : 'unexpected response shape';
409
+ }
410
+ } catch (err) {
411
+ if (!_modelCatalogCache) {
412
+ _modelCatalogError = err?.name === 'TimeoutError' ? 'backend timeout (3s)' : 'backend unreachable';
413
+ }
414
+ }
415
+ }
416
+
417
+ function isByokModelRoute() {
418
+ return session.routePreference === 'byok' || (!session.routePreference && session.isByok);
419
+ }
420
+
421
+ // PRD-076 W7b: mirror session model state into ~/.bahulam/config.json so
422
+ // picks survive restarts and the bundled Python runtime — which already
423
+ // reads `model_config` via read_local_model_config() — sees them without
424
+ // requiring a live backend fetch.
425
+ function persistSessionModelState(ctx) {
426
+ try {
427
+ const auth = ctx?.auth;
428
+ if (!auth?.saveCredentials) return;
429
+ auth.saveCredentials({
430
+ model_config: { ...(session.modelOverrides || {}) },
431
+ model_mode: session.modelMode || null,
432
+ route_preference: session.routePreference || null,
433
+ });
434
+ } catch {
435
+ // Persistence is best-effort — never break the /model command over
436
+ // a config write failure.
437
+ }
438
+ }
439
+
440
+ function modelCreditBadge(model) {
441
+ const usd = Number(model?.input_cost_usd_per_m);
442
+ if (!Number.isFinite(usd) || usd <= 0) return '';
443
+ const credits = usd * 200; // credits = provider cost × 2 × 100/USD
444
+ return `~${credits < 10 ? credits.toFixed(1) : String(Math.round(credits))} cr/M in`;
445
+ }
446
+
447
+ async function warnIfNotCurated(model, ctx) {
448
+ if (isByokModelRoute()) return;
449
+ const catalog = await fetchModelCatalog(ctx);
450
+ if (!catalog) return;
451
+ const row = catalog.find(m => m?.id === model);
452
+ if (!row) {
453
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`${model} is not in the platform catalog — the backend may reject it. See /model list.`)}\n`);
454
+ } else if (row.harness_validated === false) {
455
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`${model} is not harness-validated for the platform route — cost/quality untuned. See /model list.`)}\n`);
456
+ }
457
+ }
458
+
459
+ async function printModelCatalog(ctx) {
460
+ const catalog = await fetchModelCatalog(ctx);
461
+ if (!catalog) {
462
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Model catalog unavailable — ${_modelCatalogError || 'unknown error'}.`)}\n`);
463
+ return;
464
+ }
465
+ const curated = catalog.filter(m => m?.harness_validated);
466
+ process.stderr.write(`\n ${c.bold('Platform catalog')} ${c.dim('(harness-validated, credit-priced)')}\n`);
467
+ process.stderr.write(` ${c.gray('─'.repeat(64))}\n`);
468
+ if (!curated.length) {
469
+ process.stderr.write(` ${c.dim('(none published yet)')}\n`);
470
+ }
471
+ for (const m of curated) {
472
+ const badge = modelCreditBadge(m);
473
+ const tiers = Array.isArray(m.platform_access_tier) && m.platform_access_tier.length
474
+ ? c.dim(` [${m.platform_access_tier.join(', ')}]`)
475
+ : '';
476
+ process.stderr.write(` ${c.brand(String(m.id || '').padEnd(38))} ${badge ? c.dim(badge.padEnd(16)) : ''.padEnd(16)}${tiers}\n`);
477
+ }
478
+ const rest = catalog.length - curated.length;
479
+ if (rest > 0) {
480
+ process.stderr.write(` ${c.dim(`+${rest} more models available on the BYOK route (--route byok, own API key)`)}\n`);
481
+ }
482
+ process.stderr.write('\n');
483
+ }
484
+
485
+ function applyLaunchModelArgs(cliArgs, ctx) {
486
+ const route = String(cliArgs.route || '').trim().toLowerCase();
487
+ if (route) {
488
+ if (route === 'platform' || route === 'byok') {
489
+ session.routePreference = route;
490
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Model route:')} ${c.brand(route)}\n`);
491
+ } else {
492
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Unknown --route ${cliArgs.route} (expected platform|byok)`)}\n`);
493
+ }
494
+ }
495
+
496
+ const value = String(cliArgs.model || '').trim();
497
+ if (!value) return Promise.resolve();
498
+
499
+ if (NAMED_MODEL_MODES.has(value.toLowerCase())) {
500
+ session.modelMode = value.toLowerCase();
501
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Session model mode:')} ${c.brand(session.modelMode)}\n`);
502
+ return Promise.resolve();
503
+ }
504
+
505
+ const pending = [];
506
+ for (const part of value.split(',').map(s => s.trim()).filter(Boolean)) {
507
+ const eq = part.indexOf('=');
508
+ let role = 'reasoning';
509
+ let model = part;
510
+ if (eq > 0) {
511
+ const maybeRole = normalizeModelRole(part.slice(0, eq));
512
+ if (!maybeRole) {
513
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Unknown model role in --model: ${part.slice(0, eq)}`)}\n`);
514
+ continue;
515
+ }
516
+ role = maybeRole;
517
+ model = part.slice(eq + 1).trim();
518
+ }
519
+ if (!model) continue;
520
+ session.modelOverrides = { ...(session.modelOverrides || {}), [role]: model };
521
+ if (role === 'reasoning') session.model = model;
522
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Session ${MODEL_ROLE_LABELS[role] || role} model:`)} ${c.brand(model)}\n`);
523
+ pending.push(warnIfNotCurated(model, ctx));
524
+ }
525
+ return Promise.all(pending);
526
+ }
527
+
328
528
  function printModelStatus() {
329
529
  process.stderr.write(`\n ${c.bold('Models')}\n`);
330
530
  process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
331
531
  process.stderr.write(` ${c.gray('Active coding')} ${session.model || 'backend default'}\n`);
532
+ process.stderr.write(` ${c.gray('Route ')} ${session.routePreference || (session.isByok ? 'byok' : 'platform')}\n`);
533
+ if (session.modelMode) {
534
+ process.stderr.write(` ${c.gray('Mode ')} ${session.modelMode}\n`);
535
+ }
332
536
 
333
537
  const limits = session.modelLimits || {};
334
538
  const rows = [
@@ -356,16 +560,124 @@ function printModelStatus() {
356
560
  printModelCommandUsage();
357
561
  }
358
562
 
359
- function handleModelCommand(rest = '') {
563
+ const MODEL_FORM_ROLES = ['reasoning', 'fast', 'orchestrator', 'explore', 'plan'];
564
+
565
+ async function openModelForm(ctx) {
566
+ const limits = session.modelLimits || {};
567
+ const defaultsByRole = {
568
+ reasoning: limits.coder?.model,
569
+ fast: limits.explorer?.model,
570
+ orchestrator: limits.orchestrator?.model,
571
+ explore: limits.explorer?.model,
572
+ plan: limits.orchestrator?.model,
573
+ };
574
+ const roles = MODEL_FORM_ROLES.map(role => ({
575
+ role,
576
+ label: MODEL_ROLE_LABELS[role] || role,
577
+ current: (session.modelOverrides || {})[role] || null,
578
+ defaultLabel: defaultsByRole[role] || null,
579
+ }));
580
+ const catalog = await fetchModelCatalog(ctx);
581
+ // No curated catalog (backend down, empty table, …): fall back to the
582
+ // distinct models the backend already reported for this session so the
583
+ // form is still navigable instead of a dead single-option row.
584
+ const fallbackIds = [...new Set([
585
+ ...Object.values(limits).map(l => l?.model),
586
+ ...Object.values(session.modelOverrides || {}),
587
+ ].filter(Boolean))];
588
+ const result = await pickModelOverridesForm({
589
+ rl: ctx?._rl || null,
590
+ roles,
591
+ catalog: catalog || [],
592
+ fallbackIds,
593
+ unavailableNote: catalog ? null : (_modelCatalogError || 'backend unreachable'),
594
+ });
595
+ if (!result) {
596
+ process.stderr.write(` ${c.dim('No model changes.')}\n`);
597
+ return;
598
+ }
599
+ // Merge: form rows replace their roles; overrides on roles the form
600
+ // doesn't show (verify/debug/…) are left untouched.
601
+ const next = { ...(session.modelOverrides || {}) };
602
+ for (const role of MODEL_FORM_ROLES) delete next[role];
603
+ Object.assign(next, result.overrides);
604
+ session.modelOverrides = next;
605
+ if (result.overrides.reasoning) session.model = result.overrides.reasoning;
606
+ persistSessionModelState(ctx);
607
+ const chosen = Object.entries(result.overrides);
608
+ if (!chosen.length) {
609
+ process.stderr.write(` ${c.green('✓')} ${c.dim('All roles back to backend defaults.')}\n`);
610
+ return;
611
+ }
612
+ for (const [role, model] of chosen) {
613
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`${MODEL_ROLE_LABELS[role] || role}:`)} ${c.brand(model)}\n`);
614
+ }
615
+ }
616
+
617
+ async function handleModelCommand(rest = '', ctx) {
360
618
  const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
361
619
  if (parts.length === 0) {
620
+ if (process.stdin.isTTY) {
621
+ await openModelForm(ctx);
622
+ } else {
623
+ printModelStatus();
624
+ }
625
+ return;
626
+ }
627
+
628
+ if (parts[0] === 'status') {
362
629
  printModelStatus();
363
630
  return;
364
631
  }
365
632
 
633
+ if (parts[0] === 'form') {
634
+ await openModelForm(ctx);
635
+ return;
636
+ }
637
+
638
+ if (parts[0] === 'list' || parts[0] === 'catalog') {
639
+ await printModelCatalog(ctx);
640
+ return;
641
+ }
642
+
643
+ if (parts[0] === 'refresh') {
644
+ // Bust the in-process catalog and pull a fresh /api/models. This only
645
+ // touches the in-memory cache — persisted /model picks in
646
+ // ~/.bahulam/config.json (model_config, model_mode, route_preference)
647
+ // are untouched, so session overrides survive the refresh.
648
+ _modelCatalogCache = null;
649
+ _modelCatalogError = null;
650
+ _modelCatalogSource = null;
651
+ _backendRefreshInFlight = null;
652
+ process.stderr.write(` ${c.dim('Refreshing model catalog…')}\n`);
653
+ await refreshCatalogFromBackend(ctx);
654
+ if (_modelCatalogSource !== 'backend') {
655
+ // Backend fetch failed — restore the shipped snapshot so subsequent
656
+ // /model commands still see a catalog.
657
+ const shipped = readShippedCatalog();
658
+ if (shipped && shipped.length) {
659
+ _modelCatalogCache = shipped;
660
+ _modelCatalogSource = 'snapshot';
661
+ }
662
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Backend refresh failed — ${_modelCatalogError || 'unknown error'}. Showing shipped snapshot.`)}\n`);
663
+ }
664
+ await printModelCatalog(ctx);
665
+ return;
666
+ }
667
+
668
+ if (parts.length === 1 && NAMED_MODEL_MODES.has(parts[0].toLowerCase())) {
669
+ session.modelMode = parts[0].toLowerCase();
670
+ persistSessionModelState(ctx);
671
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Session model mode:')} ${c.brand(session.modelMode)}\n`);
672
+ process.stderr.write(` ${c.dim('The platform maps this mode to pinned models. Use /model clear to reset.')}\n`);
673
+ return;
674
+ }
675
+
366
676
  if (parts[0] === 'clear' || parts[0] === 'reset') {
367
677
  if (parts.length === 1) {
368
678
  session.modelOverrides = {};
679
+ session.modelMode = null;
680
+ persistSessionModelState(ctx);
369
681
  process.stderr.write(` ${c.green('✓')} ${c.dim('Cleared all session model overrides.')}\n`);
370
682
  return;
371
683
  }
@@ -376,6 +688,7 @@ function handleModelCommand(rest = '') {
376
688
  return;
377
689
  }
378
690
  delete session.modelOverrides[role];
691
+ persistSessionModelState(ctx);
379
692
  process.stderr.write(` ${c.green('✓')} ${c.dim(`Cleared ${MODEL_ROLE_LABELS[role] || role} model override.`)}\n`);
380
693
  return;
381
694
  }
@@ -395,8 +708,10 @@ function handleModelCommand(rest = '') {
395
708
 
396
709
  session.modelOverrides = { ...(session.modelOverrides || {}), [role]: model };
397
710
  if (role === 'reasoning') session.model = model;
711
+ persistSessionModelState(ctx);
398
712
  process.stderr.write(` ${c.green('✓')} ${c.dim(`Session ${MODEL_ROLE_LABELS[role] || role} model override:`)} ${c.brand(model)}\n`);
399
713
  process.stderr.write(` ${c.dim('Use /model clear or /model clear <role> to return to backend settings.')}\n`);
714
+ await warnIfNotCurated(model, ctx);
400
715
  }
401
716
 
402
717
  function stripPathQuotes(value) {
@@ -632,8 +947,10 @@ function commandCompletions(line) {
632
947
  const top = ['/help', '/status', '/plan', '/tasks', '/history', '/settings', '/why'];
633
948
  const namespaced = HELP_GROUPS.flatMap(g => g.commands.map(([name]) => name.split(/\s+/)[0]));
634
949
  const all = [...new Set([...top, ...namespaced, ...Object.keys(COMMANDS), '/quit'])].sort();
635
- const hits = all.filter(cmd => cmd.startsWith(line));
636
- return hits.length ? hits : all;
950
+ // No fallback-to-all: a non-matching prefix ("/Users/...", a pasted
951
+ // path) must yield NOTHING so the hint overlay hides, not the full
952
+ // catalog. Bare "/" still matches every command via startsWith.
953
+ return all.filter(cmd => cmd.startsWith(line));
637
954
  }
638
955
 
639
956
  function slashCommandSuggestions(line, limit = 5) {
@@ -1079,7 +1396,10 @@ function renderEvent(event) {
1079
1396
  runtime.lastRenderedBlock = 'thinking';
1080
1397
  session._lastEmittedThinking = text;
1081
1398
  }
1082
- startSpinner(text.slice(0, 80));
1399
+ // Shared 'thinking' phase — the elapsed clock keeps counting
1400
+ // across successive thinking events instead of resetting per
1401
+ // thought, so the user sees "… · 24s" during long reasoning.
1402
+ startSpinner(text.slice(0, 80), { phase: 'thinking' });
1083
1403
  // Capture reasoning so /why can replay it.
1084
1404
  session.lastReasoning = text;
1085
1405
  }
@@ -1099,17 +1419,20 @@ function renderEvent(event) {
1099
1419
  }
1100
1420
  }
1101
1421
  if (text) {
1102
- renderBlockBoundary('content');
1103
- if (!runtime.contentHeaderPrinted) {
1104
- process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
1105
- runtime.contentHeaderPrinted = true;
1106
- }
1107
1422
  const rendered = renderMarkdown(text);
1108
- for (const line of rendered.split('\n')) {
1109
- process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
1423
+ const lines = transcriptRenderableLines(rendered);
1424
+ if (lines.length) {
1425
+ renderBlockBoundary('content', { compactSame: true });
1426
+ if (!runtime.contentHeaderPrinted) {
1427
+ process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
1428
+ runtime.contentHeaderPrinted = true;
1429
+ }
1430
+ for (const line of lines) {
1431
+ process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
1432
+ }
1433
+ runtime.renderedContentThisTurn = true;
1434
+ runtime.lastRenderedBlock = 'content';
1110
1435
  }
1111
- runtime.renderedContentThisTurn = true;
1112
- runtime.lastRenderedBlock = 'content';
1113
1436
  }
1114
1437
  break;
1115
1438
  }
@@ -1229,6 +1552,14 @@ function renderEvent(event) {
1229
1552
  break;
1230
1553
  }
1231
1554
 
1555
+ case 'file_diff': {
1556
+ stopSpinner();
1557
+ flushContent();
1558
+ flushPendingHead();
1559
+ renderFileDiffEvent(data);
1560
+ break;
1561
+ }
1562
+
1232
1563
  case 'plan': {
1233
1564
  stopSpinner();
1234
1565
  flushContent();
@@ -1335,7 +1666,12 @@ function renderEvent(event) {
1335
1666
  runtime.lastRenderedBlock = 'subagent';
1336
1667
  session.inSubAgent = inSubAgentBlock(); // kept for legacy readers
1337
1668
  session.subAgentCounts[agentType] = (session.subAgentCounts[agentType] || 0) + 1;
1338
- startSpinner(`${agentType}: working...`);
1669
+ // Fixed-height live tool window under the spinner (queue mode):
1670
+ // inner tool calls stream here instead of flooding the transcript.
1671
+ setSubAgentWindowActive(true);
1672
+ // Phase per sub-agent run: the status line counts elapsed time and
1673
+ // tool calls live ("plan agent · 4 calls · 32s") for the whole run.
1674
+ startSpinner(`${agentType} agent`, { phase: `sub:${agentType}:${Date.now()}` });
1339
1675
  break;
1340
1676
  }
1341
1677
 
@@ -1345,16 +1681,24 @@ function renderEvent(event) {
1345
1681
  const agentType = data?.type || 'sub-agent';
1346
1682
  const tool = data?.tool || '';
1347
1683
  if (!tool) break;
1684
+ // Feed the live window from THIS event — it always fires (55/55 in
1685
+ // observed runs), unlike the inner tool_call render path which
1686
+ // diverts for explore-category tools and folded verbosity modes.
1687
+ // Dedup-consecutive inside the push keeps repeat tools quiet.
1688
+ pushSubAgentWindowLine(`→ ${tool}`);
1348
1689
  // Don't clobber an active explore-run spinner. "exploring · 5 read ·
1349
1690
  // 2 searched" is more informative than "explore → search_code", and
1350
1691
  // sub_agent_tool fires on every step of a sub-agent — otherwise the
1351
1692
  // spinner would flip-flop between the two texts and read as blank.
1352
1693
  if (runtime.exploreRun.lineActive && isExploreTool(tool)) break;
1694
+ // Same phase → clock keeps counting; the counter shows progress.
1695
+ bumpSpinnerProgress();
1353
1696
  updateSpinner(`${agentType} → ${tool}`);
1354
1697
  break;
1355
1698
  }
1356
1699
 
1357
1700
  case 'sub_agent_complete': {
1701
+ setSubAgentWindowActive(false);
1358
1702
  stopSpinner();
1359
1703
  clearPendingHead();
1360
1704
  flushFoldedSubAgentTools();
@@ -1466,6 +1810,8 @@ function renderEvent(event) {
1466
1810
  }
1467
1811
 
1468
1812
  case 'complete': {
1813
+ // Fire first_answer on the first turn's completion
1814
+ if (session.turns === 1 && session.user) telemetry.track('first_answer', {});
1469
1815
  stopSpinner();
1470
1816
  flushContent();
1471
1817
  flushFoldedSubAgentTools();
@@ -1474,17 +1820,20 @@ function renderEvent(event) {
1474
1820
 
1475
1821
  const summary = data?.summary || '';
1476
1822
  if (summary && !runtime.renderedContentThisTurn) {
1477
- renderBlockBoundary('content');
1478
- if (!runtime.contentHeaderPrinted) {
1479
- process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
1480
- runtime.contentHeaderPrinted = true;
1481
- }
1482
1823
  const rendered = renderMarkdown(summary);
1483
- for (const line of rendered.split('\n')) {
1484
- process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
1824
+ const lines = transcriptRenderableLines(rendered);
1825
+ if (lines.length) {
1826
+ renderBlockBoundary('content', { compactSame: true });
1827
+ if (!runtime.contentHeaderPrinted) {
1828
+ process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
1829
+ runtime.contentHeaderPrinted = true;
1830
+ }
1831
+ for (const line of lines) {
1832
+ process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
1833
+ }
1834
+ runtime.renderedContentThisTurn = true;
1835
+ runtime.lastRenderedBlock = 'content';
1485
1836
  }
1486
- runtime.renderedContentThisTurn = true;
1487
- runtime.lastRenderedBlock = 'content';
1488
1837
  }
1489
1838
 
1490
1839
  // Update session token counts
@@ -1871,6 +2220,8 @@ async function prepareDirectAgentRunContext(ctx, instruction = '') {
1871
2220
  execContext.model_overrides = modelOverrides;
1872
2221
  if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
1873
2222
  }
2223
+ if (session.modelMode) execContext.model_mode = session.modelMode;
2224
+ if (session.routePreference) execContext.model_route = session.routePreference;
1874
2225
  return execContext;
1875
2226
  }
1876
2227
 
@@ -2013,8 +2364,10 @@ async function handleCommand(input, ctx) {
2013
2364
 
2014
2365
  case '/login':
2015
2366
  process.stderr.write(`${c.brand('Starting login flow...')}\n`);
2367
+ telemetry.track('login_shown', { method: 'repl_command' });
2016
2368
  try {
2017
2369
  await ctx.auth.login();
2370
+ telemetry.track('login_completed', { method: 'repl_command' });
2018
2371
  process.stderr.write(`${c.green('✓ Login successful!')}\n`);
2019
2372
  await fetchUser(ctx);
2020
2373
  } catch (err) {
@@ -2455,7 +2808,7 @@ async function handleCommand(input, ctx) {
2455
2808
  }
2456
2809
 
2457
2810
  case '/model': {
2458
- handleModelCommand(rest);
2811
+ await handleModelCommand(rest, ctx);
2459
2812
  return;
2460
2813
  }
2461
2814
 
@@ -2520,6 +2873,67 @@ async function handleCommand(input, ctx) {
2520
2873
  return;
2521
2874
  }
2522
2875
 
2876
+ case '/auto': {
2877
+ // Session autopilot for long-running jobs: auto-approve routine
2878
+ // writes/shell while STILL prompting for dangerous tiers (rm,
2879
+ // force-push, command substitution, …) and never overriding hard
2880
+ // safety blocks. Distinct from the launch-time --freeswim flag,
2881
+ // which approves everything including dangerous tiers.
2882
+ const sub = (rest || '').trim().toLowerCase();
2883
+ if (sub === 'off') {
2884
+ ctx.approval.approveAll = false;
2885
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Auto mode off — approvals prompt again.')}\n`);
2886
+ return;
2887
+ }
2888
+ if (sub === '' || sub === 'on') {
2889
+ ctx.approval.approveAll = true;
2890
+ process.stderr.write(` ${c.green('✓')} ${c.bold('Auto mode on')} ${c.dim('— routine tool calls auto-approve this session.')}\n`);
2891
+ process.stderr.write(` ${c.dim('Still prompts: dangerous shell (rm/force-push/substitution), protected files.')}\n`);
2892
+ process.stderr.write(` ${c.dim('Hard safety blocks stay enforced. Disable with /auto off · inspect with /approvals.')}\n`);
2893
+ process.stderr.write(` ${c.dim('Tip: start your message with #auto to switch the backend agent into autonomous mode too.')}\n`);
2894
+ return;
2895
+ }
2896
+ // status / anything else → show current mode
2897
+ process.stderr.write(` ${c.dim('Approval mode:')} ${ctx.approval.getModeLabel()}\n`);
2898
+ process.stderr.write(` ${c.dim('Usage: /auto [on|off|status]')}\n`);
2899
+ return;
2900
+ }
2901
+
2902
+ case '/approvals': {
2903
+ const parts = (rest || '').trim().split(/\s+/).filter(Boolean);
2904
+ const action = (parts[0] || 'list').toLowerCase();
2905
+ if (action === 'clear') {
2906
+ const wasActive = ctx.approval.revoke();
2907
+ process.stderr.write(wasActive
2908
+ ? ` ${c.green('✓')} ${c.dim('All session approvals cleared.')}\n`
2909
+ : ` ${c.gray('No session approvals were active.')}\n`);
2910
+ return;
2911
+ }
2912
+ if (action === 'allow' && parts[1]) {
2913
+ const tool = parts[1];
2914
+ ctx.approval.approvedToolTypes.add(tool);
2915
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Auto-approving ${tool} for this session (dangerous tiers still prompt).`)}\n`);
2916
+ return;
2917
+ }
2918
+ if (action === 'remove' && parts[1]) {
2919
+ const removed = ctx.approval.approvedToolTypes.delete(parts[1]);
2920
+ process.stderr.write(removed
2921
+ ? ` ${c.green('✓')} ${c.dim(`${parts[1]} will prompt again.`)}\n`
2922
+ : ` ${c.gray(`${parts[1]} had no session grant.`)}\n`);
2923
+ return;
2924
+ }
2925
+ // list (default)
2926
+ const s = ctx.approval.getSummary();
2927
+ process.stderr.write(`\n ${c.bold('Session approvals')}\n`);
2928
+ process.stderr.write(` ${c.dim('Mode')} ${ctx.approval.getModeLabel()}\n`);
2929
+ process.stderr.write(` ${c.dim('Allow-all')} ${s.autoApproveAll ? c.green('on') : c.dim('off')}\n`);
2930
+ process.stderr.write(` ${c.dim('Tool grants')} ${s.autoApprovedTypes.length ? s.autoApprovedTypes.join(', ') : c.dim('none')}\n`);
2931
+ process.stderr.write(` ${c.dim('Trust rules')} ${c.dim(`${s.trust.sessionRules || 0} session · ${s.trust.projectRules || 0} project`)}\n`);
2932
+ process.stderr.write(` ${c.dim('Decisions')} ${c.dim(`${s.approved} approved · ${s.denied} denied`)}\n`);
2933
+ process.stderr.write(` ${c.dim('Edit: /approvals allow <tool> · /approvals remove <tool> · /approvals clear · /auto [on|off]')}\n\n`);
2934
+ return;
2935
+ }
2936
+
2523
2937
  case '/sessions': {
2524
2938
  const resumable = await listResumableSessions();
2525
2939
  if (resumable.length === 0) {
@@ -2784,6 +3198,54 @@ export async function startTerminalRepl() {
2784
3198
 
2785
3199
  const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints, effectivePolicy, latestProjectContext, latestEnvelope, pendingVisionPaths: [] };
2786
3200
 
3201
+ let startupOutputRow = 1;
3202
+ let startupOutputCol = 1;
3203
+
3204
+ function trackStartupOutput(chunk) {
3205
+ if (!process.stderr.isTTY || term().plain) return;
3206
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
3207
+ if (!text) return;
3208
+ const clean = text
3209
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
3210
+ .replace(/\x1b[()][A-Za-z0-9]/g, '');
3211
+ const width = Math.max(1, process.stderr.columns || process.stdout.columns || 80);
3212
+ for (const ch of clean) {
3213
+ if (ch === '\r') {
3214
+ startupOutputCol = 1;
3215
+ continue;
3216
+ }
3217
+ if (ch === '\n') {
3218
+ startupOutputRow++;
3219
+ startupOutputCol = 1;
3220
+ continue;
3221
+ }
3222
+ startupOutputCol++;
3223
+ if (startupOutputCol > width) {
3224
+ startupOutputRow++;
3225
+ startupOutputCol = 1;
3226
+ }
3227
+ }
3228
+ }
3229
+
3230
+ function startStartupOutputTracking() {
3231
+ if (!process.stderr.isTTY || term().plain) return () => {};
3232
+ const originalWrite = process.stderr.write;
3233
+ function trackedStartupWrite(chunk, ...args) {
3234
+ trackStartupOutput(chunk);
3235
+ return originalWrite.call(this, chunk, ...args);
3236
+ }
3237
+ process.stderr.write = trackedStartupWrite;
3238
+ return () => {
3239
+ if (process.stderr.write === trackedStartupWrite) {
3240
+ process.stderr.write = originalWrite;
3241
+ }
3242
+ };
3243
+ }
3244
+
3245
+ function startupCursorSeed() {
3246
+ return { row: startupOutputRow, col: startupOutputCol };
3247
+ }
3248
+
2787
3249
  async function startNewSession({ announce = true } = {}) {
2788
3250
  stopSpinner();
2789
3251
  flushContent();
@@ -2798,6 +3260,8 @@ export async function startTerminalRepl() {
2798
3260
  model: session.model,
2799
3261
  modelLimits: session.modelLimits,
2800
3262
  modelOverrides: session.modelOverrides,
3263
+ modelMode: session.modelMode,
3264
+ routePreference: session.routePreference,
2801
3265
  isByok: session.isByok,
2802
3266
  subscriptionTier: session.subscriptionTier,
2803
3267
  creditsTotal: session.creditsTotal,
@@ -2840,6 +3304,8 @@ export async function startTerminalRepl() {
2840
3304
  model: preserved.model,
2841
3305
  modelLimits: preserved.modelLimits,
2842
3306
  modelOverrides: preserved.modelOverrides,
3307
+ modelMode: preserved.modelMode,
3308
+ routePreference: preserved.routePreference,
2843
3309
  blockedOps: 0,
2844
3310
  delegations: [],
2845
3311
  phases: [],
@@ -3103,57 +3569,86 @@ export async function startTerminalRepl() {
3103
3569
  // ── Print banner + preflight + init BEFORE mounting the status bar ──
3104
3570
  // The status bar shrinks the scroll region; if it mounts first, the
3105
3571
  // banner scrolls off-screen before the user ever sees it.
3106
- printBanner(auth);
3572
+ const stopStartupOutputTracking = startStartupOutputTracking();
3573
+ let dockCursor = startupCursorSeed();
3574
+ try {
3575
+ printBanner(auth);
3107
3576
 
3108
- // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
3109
- // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
3110
- if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
3111
- try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
3112
- catch { /* preflight is best-effort */ }
3113
- }
3577
+ // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
3578
+ // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
3579
+ if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
3580
+ try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
3581
+ catch { /* preflight is best-effort */ }
3582
+ }
3114
3583
 
3115
- // ── Initialization ──
3116
- process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
3117
- await fetchUser(ctx);
3584
+ // ── Initialization ──
3585
+ process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
3586
+ await fetchUser(ctx);
3118
3587
 
3119
- // Clear the spinner line
3120
- process.stderr.write(`\r${' '.repeat(60)}\r`);
3121
- process.stderr.write(` ${c.green('✓')} ${c.dim('Ready; projects will be indexed on demand')}\n`);
3122
- if (session.user) {
3123
- process.stderr.write(` ${c.green('✓')} ${c.dim(`Logged in as ${session.user.github_username || session.user.email || 'user'}`)}\n`);
3124
- }
3125
- // ── Resume previous session ──
3126
- if (cliArgs.resume) {
3127
- const lastSession = cliArgs.resumeSessionId
3128
- ? { sessionId: cliArgs.resumeSessionId }
3129
- : sessionMgr.getLastSession();
3130
-
3131
- if (lastSession) {
3132
- const resumed = await activateResumedSession(lastSession.sessionId, 'startup');
3133
- if (resumed.ok) {
3134
- process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messageCountLabel(resumed.messages)}`)}`);
3135
- process.stderr.write(` ${c.dim('· project')} ${c.brand(path.basename(safeCwd()))}`);
3136
- process.stderr.write(` ${c.dim( agent ${resumed.historyMode}`)}`);
3137
- if (resumed.switchedProject) process.stderr.write(` ${c.dim('(cwd restored)')}`);
3138
- if (resumed.projectMissing) process.stderr.write(` ${c.yellow('(saved project path unavailable; using current cwd)')}`);
3139
- if (resumed.instruction) process.stderr.write(` ${c.dim('—')} ${c.dim(resumed.instruction.slice(0, 50))}`);
3140
- process.stderr.write('\n');
3141
- renderResumePreview(resumed, { renderEvent });
3588
+ // Clear the spinner line
3589
+ process.stderr.write(`\r${' '.repeat(60)}\r`);
3590
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Ready; projects will be indexed on demand')}\n`);
3591
+
3592
+ // PRD-076 W7b: restore persisted /model picks from ~/.bahulam/config.json
3593
+ // before applying CLI-flag overrides, so `bahulam-code` alone re-uses
3594
+ // last session's choices and `bahulam-code --model foo` still wins.
3595
+ try {
3596
+ const persisted = auth.loadCredentials();
3597
+ if (persisted?.modelConfig && Object.keys(persisted.modelConfig).length) {
3598
+ session.modelOverrides = { ...persisted.modelConfig };
3599
+ if (persisted.modelConfig.reasoning) session.model = persisted.modelConfig.reasoning;
3600
+ }
3601
+ if (persisted?.modelMode) session.modelMode = persisted.modelMode;
3602
+ if (persisted?.routePreference) session.routePreference = persisted.routePreference;
3603
+ } catch { /* best-effort restore */ }
3604
+
3605
+ // --model / --route launch overrides (after fetchUser so they win
3606
+ // over the profile default; catalog validation is fail-open).
3607
+ if (cliArgs.model || cliArgs.route) {
3608
+ try { await applyLaunchModelArgs(cliArgs, ctx); } catch {}
3609
+ }
3610
+ if (session.user) {
3611
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Logged in as ${session.user.github_username || session.user.email || 'user'}`)}\n`);
3612
+ }
3613
+ // ── Resume previous session ──
3614
+ if (cliArgs.resume) {
3615
+ const lastSession = cliArgs.resumeSessionId
3616
+ ? { sessionId: cliArgs.resumeSessionId }
3617
+ : sessionMgr.getLastSession();
3618
+
3619
+ if (lastSession) {
3620
+ const resumed = await activateResumedSession(lastSession.sessionId, 'startup');
3621
+ if (resumed.ok) {
3622
+ process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messageCountLabel(resumed.messages)}`)}`);
3623
+ process.stderr.write(` ${c.dim('· project')} ${c.brand(path.basename(safeCwd()))}`);
3624
+ process.stderr.write(` ${c.dim(`· agent ${resumed.historyMode}`)}`);
3625
+ if (resumed.switchedProject) process.stderr.write(` ${c.dim('(cwd restored)')}`);
3626
+ if (resumed.projectMissing) process.stderr.write(` ${c.yellow('(saved project path unavailable; using current cwd)')}`);
3627
+ if (resumed.instruction) process.stderr.write(` ${c.dim('—')} ${c.dim(resumed.instruction.slice(0, 50))}`);
3628
+ process.stderr.write('\n');
3629
+ renderResumePreview(resumed, { renderEvent });
3630
+ } else {
3631
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(resumed.reason || 'No conversation found for session ' + lastSession.sessionId)}\n`);
3632
+ }
3142
3633
  } else {
3143
- process.stderr.write(` ${c.yellow('!')} ${c.dim(resumed.reason || 'No conversation found for session ' + lastSession.sessionId)}\n`);
3634
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
3144
3635
  }
3145
- } else {
3146
- process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
3147
3636
  }
3148
- }
3149
3637
 
3150
- process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
3638
+ process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
3639
+ } finally {
3640
+ dockCursor = startupCursorSeed();
3641
+ stopStartupOutputTracking();
3642
+ }
3151
3643
 
3152
3644
  // Keep one bottom-reserved UI surface: the fixed input dock. The older
3153
3645
  // status bar used the same terminal scroll-region primitive, so mounting
3154
3646
  // both would make prompt placement unpredictable.
3155
3647
  orbitRef.current = createOrbit();
3156
- const inputDockActive = mountInputDock();
3648
+ const inputDockActive = mountInputDock({
3649
+ initialContentRow: dockCursor.row,
3650
+ initialContentCol: dockCursor.col,
3651
+ });
3157
3652
  if (inputDockActive) {
3158
3653
  process.on('beforeExit', unmountInputDock);
3159
3654
  process.on('exit', unmountInputDock);
@@ -3338,50 +3833,92 @@ export async function startTerminalRepl() {
3338
3833
  readline.cursorTo(process.stderr, col);
3339
3834
  }
3340
3835
 
3341
- function renderSlashHint(line = '', { preserveSelection = false } = {}) {
3836
+ // Hint frames must NOT go through the patched std streams — under the
3837
+ // render queue, redirected writes are sanitized (cursor CSI stripped)
3838
+ // and serialized into the transcript. That's exactly the "pasted a
3839
+ // path, command list flooded the message area" bug. queue.raw() is the
3840
+ // serialized trusted channel for cursor-addressed frame writes;
3841
+ // readline helpers are the legacy no-queue path.
3842
+ function writeHintFrame(frame) {
3843
+ if (rqueue.isActive()) {
3844
+ rqueue.raw(frame);
3845
+ return;
3846
+ }
3847
+ process.stderr.write(frame);
3848
+ }
3849
+
3850
+ let slashHintTimer = null;
3851
+
3852
+ function renderSlashHintNow(line = '', { preserveSelection = false } = {}) {
3342
3853
  if (!process.stderr.isTTY || term().plain || !inputActive || !promptBottomPaddingLines()) return;
3343
3854
  const rows = promptBottomPaddingLines();
3344
3855
  const suggestions = slashCommandSuggestions(line, Math.min(5, rows));
3856
+ // Nothing matches (pasted path, typo) → hide instead of rendering
3857
+ // an empty/robotic frame.
3858
+ if (!suggestions.length) {
3859
+ if (slashHintVisible) clearSlashHint();
3860
+ return;
3861
+ }
3345
3862
  const cols = process.stdout.columns || 80;
3346
3863
  if (!preserveSelection || line !== slashHintLine) slashHintSelected = 0;
3347
3864
  slashHintItems = suggestions;
3348
3865
  slashHintLine = line;
3349
3866
  if (slashHintSelected >= slashHintItems.length) slashHintSelected = Math.max(0, slashHintItems.length - 1);
3350
3867
 
3351
- readline.moveCursor(process.stderr, 0, 1);
3868
+ // Compose the whole frame as ONE write: down a row, paint each hint
3869
+ // row (clear + text), then back up to the input row.
3870
+ let frame = '\x1b[1B';
3352
3871
  for (let i = 0; i < rows; i++) {
3353
- readline.clearLine(process.stderr, 0);
3354
- readline.cursorTo(process.stderr, 0);
3872
+ frame += '\x1b[2K\x1b[G';
3355
3873
  const item = suggestions[i];
3356
3874
  if (item) {
3357
3875
  const marker = i === slashHintSelected ? c.brand('›') : c.dim(' ');
3358
3876
  const command = item.command.padEnd(13);
3359
3877
  const maxDesc = Math.max(0, cols - 21);
3360
3878
  const desc = truncateHintText(item.description, maxDesc);
3361
- process.stderr.write(` ${marker} ${c.brand(command)}${desc ? c.dim(desc) : ''}`);
3879
+ frame += ` ${marker} ${c.brand(command)}${desc ? c.dim(desc) : ''}`;
3362
3880
  }
3363
- if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
3881
+ if (i < rows - 1) frame += '\x1b[1B';
3364
3882
  }
3365
- readline.moveCursor(process.stderr, 0, -rows);
3883
+ frame += `\x1b[${rows}A`;
3884
+ writeHintFrame(frame);
3366
3885
  restoreReadlineCursor();
3367
- slashHintVisible = suggestions.length > 0;
3886
+ slashHintVisible = true;
3368
3887
  slashHintRowsVisible = rows;
3369
3888
  }
3370
3889
 
3890
+ // Debounced entry point: a bracketed paste delivers the buffer as many
3891
+ // rapid input events — rendering per event is what turned one paste
3892
+ // into dozens of hint frames. Selection-preserving calls (arrow keys)
3893
+ // stay immediate for responsiveness.
3894
+ function renderSlashHint(line = '', opts = {}) {
3895
+ if (opts.preserveSelection) {
3896
+ if (slashHintTimer) { clearTimeout(slashHintTimer); slashHintTimer = null; }
3897
+ renderSlashHintNow(line, opts);
3898
+ return;
3899
+ }
3900
+ if (slashHintTimer) clearTimeout(slashHintTimer);
3901
+ slashHintTimer = setTimeout(() => {
3902
+ slashHintTimer = null;
3903
+ renderSlashHintNow(typeof rl?.line === 'string' ? rl.line : line, opts);
3904
+ }, 24);
3905
+ }
3906
+
3371
3907
  function clearSlashHint({ restoreCursor: shouldRestoreCursor = true } = {}) {
3908
+ if (slashHintTimer) { clearTimeout(slashHintTimer); slashHintTimer = null; }
3372
3909
  if (!slashHintVisible || !process.stderr.isTTY || term().plain) {
3373
3910
  slashHintVisible = false;
3374
3911
  slashHintRowsVisible = 0;
3375
3912
  return;
3376
3913
  }
3377
3914
  const rows = slashHintRowsVisible || promptBottomPaddingLines() || 1;
3378
- readline.moveCursor(process.stderr, 0, 1);
3915
+ let frame = '\x1b[1B';
3379
3916
  for (let i = 0; i < rows; i++) {
3380
- readline.clearLine(process.stderr, 0);
3381
- readline.cursorTo(process.stderr, 0);
3382
- if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
3917
+ frame += '\x1b[2K\x1b[G';
3918
+ if (i < rows - 1) frame += '\x1b[1B';
3383
3919
  }
3384
- readline.moveCursor(process.stderr, 0, -rows);
3920
+ frame += `\x1b[${rows}A`;
3921
+ writeHintFrame(frame);
3385
3922
  // The dock's bottom rule + tips row live in the rows we just cleared.
3386
3923
  // Repaint the frame (input row untouched) so they reappear.
3387
3924
  if (isInputDockMounted()) redrawDockFrame();
@@ -3778,6 +4315,9 @@ export async function startTerminalRepl() {
3778
4315
  session.history.push(userMessage);
3779
4316
  session.agentHistory.push(userMessage);
3780
4317
  session.turns++;
4318
+ // Fire first_prompt on user's first turn
4319
+ if (session.turns === 1) telemetry.track('first_prompt', {});
4320
+
3781
4321
  session.toolCalls = 0;
3782
4322
  session.subAgentToolCalls = 0;
3783
4323
  session.lastTask = originalInput;
@@ -3851,6 +4391,21 @@ export async function startTerminalRepl() {
3851
4391
  }
3852
4392
  runtime.afterContentFlush = focusExecutionInput;
3853
4393
 
4394
+ function printExecutionInstruction(instruction) {
4395
+ if (isInputDockMounted()) {
4396
+ clearInputPrompt();
4397
+ moveToContent();
4398
+ } else if (executionInputVisible) {
4399
+ process.stderr.write('\n');
4400
+ }
4401
+ renderBlockBoundary('user', { compactSame: true });
4402
+ process.stderr.write(`${transcriptHeader('you', { tone: 'user' })} ${paint.text.dim('follow-up')}\n`);
4403
+ for (const line of String(instruction || '').split('\n')) {
4404
+ process.stderr.write(`${transcriptLine(line, { tone: 'user' })}\n`);
4405
+ }
4406
+ runtime.lastRenderedBlock = 'user';
4407
+ }
4408
+
3854
4409
  async function submitExecutionInstruction() {
3855
4410
  const instruction = executionInputBuffer.trim();
3856
4411
  executionInputBuffer = '';
@@ -3869,18 +4424,14 @@ export async function startTerminalRepl() {
3869
4424
  executionInputVisible = false;
3870
4425
  return;
3871
4426
  }
4427
+ printExecutionInstruction(instruction);
3872
4428
  if (isInputDockMounted()) {
3873
- clearInputPrompt();
3874
- moveToContent();
3875
- process.stderr.write(`${executionInputPrefix()}${instruction}\n`);
3876
4429
  renderDockInput(executionInputPrefix(), '', {
3877
4430
  context: buildContextStrip(),
3878
4431
  meta: buildDockMeta(),
3879
4432
  tips: executionInputTips(),
3880
4433
  });
3881
4434
  moveToContent();
3882
- } else if (executionInputVisible) {
3883
- process.stderr.write('\n');
3884
4435
  }
3885
4436
  executionInputVisible = false;
3886
4437
  // Live steering (PRD-081 §5.2): submit through the dedicated
@@ -3906,18 +4457,26 @@ export async function startTerminalRepl() {
3906
4457
  });
3907
4458
 
3908
4459
  if (status === 'accepted') {
4460
+ renderBlockBoundary('status', { compactSame: true });
3909
4461
  process.stderr.write(` ${c.green('↳')} ${c.dim('sent to running agent')}\n`);
4462
+ runtime.lastRenderedBlock = 'status';
3910
4463
  } else if (status === 'duplicate') {
4464
+ renderBlockBoundary('status', { compactSame: true });
3911
4465
  process.stderr.write(` ${c.dim('↳ already sent (idempotent)')}\n`);
4466
+ runtime.lastRenderedBlock = 'status';
3912
4467
  } else if (status === 'queued_next_turn') {
3913
4468
  _queuedLines.push(instruction);
4469
+ renderBlockBoundary('status', { compactSame: true });
3914
4470
  process.stderr.write(` ${c.yellow('↳')} ${c.dim('task ended — queued for next turn')}\n`);
4471
+ runtime.lastRenderedBlock = 'status';
3915
4472
  } else {
3916
4473
  // no_task, error, or unknown — fall back to next-turn queue so the
3917
4474
  // user's text is never silently lost.
3918
4475
  _queuedLines.push(instruction);
3919
4476
  const errBits = result && result.error ? ` ${c.dim(`(${String(result.error).slice(0, 80)})`)}` : '';
4477
+ renderBlockBoundary('status', { compactSame: true });
3920
4478
  process.stderr.write(` ${c.yellow('↳')} ${c.dim('queued for next turn')}${errBits}\n`);
4479
+ runtime.lastRenderedBlock = 'status';
3921
4480
  }
3922
4481
  }
3923
4482
 
@@ -4147,8 +4706,6 @@ export async function startTerminalRepl() {
4147
4706
  moveToContent();
4148
4707
  }
4149
4708
  startContentStream();
4150
- process.stderr.write(`\n${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
4151
- runtime.contentHeaderPrinted = true;
4152
4709
 
4153
4710
  // Immediate feedback so the screen isn't blank between submit and the
4154
4711
  // first backend event. The first `status`, `thinking`, or `content_*`
@@ -4203,6 +4760,8 @@ export async function startTerminalRepl() {
4203
4760
  execContext.model_overrides = modelOverrides;
4204
4761
  if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
4205
4762
  }
4763
+ if (session.modelMode) execContext.model_mode = session.modelMode;
4764
+ if (session.routePreference) execContext.model_route = session.routePreference;
4206
4765
  // PRD-071: seed work_scope from CLI so the backend has a byte-stable
4207
4766
  // scope block from turn 1. Uses projectResources already gathered by
4208
4767
  // the envelope above.