@axplusb/kepler 2.0.0 → 2.0.3

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.
@@ -37,31 +37,44 @@ const FAIL = (s) => `${paint.state.danger('[✗]')} ${s}`;
37
37
 
38
38
  // ── Individual checks (each returns { status, label, hint? }) ──────────
39
39
 
40
- function checkAuthToken(auth) {
40
+ async function checkAuthAndBackend(auth, { timeoutMs = 2500 } = {}) {
41
41
  const creds = auth.loadCredentials();
42
- if (creds.token) return { status: 'ok', label: `Auth token` };
43
- return { status: 'warn', label: 'Auth token missing', hint: '/login to sign in' };
44
- }
42
+ const hasToken = !!creds.token;
43
+ const url = creds.backendUrl;
45
44
 
46
- function checkProviderKey(auth) {
47
- const creds = auth.loadCredentials();
48
- if (creds.openRouterKey) return { status: 'ok', label: 'OpenRouter key' };
49
- if (creds.anthropicKey) return { status: 'ok', label: 'Anthropic key' };
50
- if (creds.openaiKey) return { status: 'ok', label: 'OpenAI key' };
51
- if (creds.googleKey) return { status: 'ok', label: 'Google key' };
52
- return { status: 'warn', label: 'No model provider key configured', hint: 'set OPENROUTER_API_KEY or run /config' };
53
- }
45
+ // No token: just probe whether the backend is reachable so we can hint
46
+ // /login when it makes sense.
47
+ if (!hasToken) {
48
+ const reachable = url ? await ping(url, timeoutMs).catch(() => false) : false;
49
+ return reachable
50
+ ? { status: 'warn', label: 'Not signed in · backend ready', hint: '/login to sign in' }
51
+ : { status: 'warn', label: 'Not signed in · backend offline', hint: '/login once the network is back' };
52
+ }
54
53
 
55
- async function checkBackend(auth, { timeoutMs = 1500 } = {}) {
56
- const creds = auth.loadCredentials();
57
- const url = creds.backendUrl;
58
- if (!url) return { status: 'warn', label: 'Backend not configured' };
54
+ // Token present: real authenticated round-trip against /api/user/me.
55
+ // Three outcomes: valid (200), expired (401/403), unreachable (network).
59
56
  try {
60
- const reachable = await ping(url, timeoutMs);
61
- if (reachable) return { status: 'ok', label: `Backend ${shorten(url, 48)}` };
62
- return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable — check network or start backend' };
57
+ const ctrl = new AbortController();
58
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
59
+ let resp;
60
+ try {
61
+ resp = await fetch(`${url}/api/user/me`, {
62
+ headers: { 'Authorization': `Bearer ${creds.token}` },
63
+ signal: ctrl.signal,
64
+ });
65
+ } finally { clearTimeout(t); }
66
+
67
+ if (resp.ok) {
68
+ const user = await resp.json().catch(() => null);
69
+ const who = user?.github_username || user?.email || 'user';
70
+ return { status: 'ok', label: `Signed in as ${who} · connected` };
71
+ }
72
+ if (resp.status === 401 || resp.status === 403) {
73
+ return { status: 'warn', label: 'Token expired · connected', hint: '/login again to refresh' };
74
+ }
75
+ return { status: 'warn', label: `Backend returned ${resp.status}`, hint: 'try again shortly' };
63
76
  } catch {
64
- return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable' };
77
+ return { status: 'warn', label: 'Signed in · backend offline', hint: 'check network or try again shortly' };
65
78
  }
66
79
  }
67
80
 
@@ -83,25 +96,32 @@ function checkGit(cwd) {
83
96
  function checkLinters(cwd) {
84
97
  const present = [];
85
98
  const missing = [];
86
- for (const [name, kind] of LINTERS) {
87
- if (which(name)) present.push({ name, kind });
88
- else if (projectUses(cwd, kind)) missing.push({ name, kind });
99
+ for (const linter of LINTERS) {
100
+ if (which(linter.bin)) present.push(linter);
101
+ else if (projectUses(cwd, linter.kind)) missing.push(linter);
89
102
  }
90
103
  if (present.length === 0 && missing.length === 0) {
91
104
  return { status: 'ok', label: 'Linters none required' };
92
105
  }
93
106
  if (missing.length === 0) {
94
- return { status: 'ok', label: `Linters ${present.map(p => p.name).join(', ')}` };
107
+ return { status: 'ok', label: `Linters ${present.map(p => p.bin).join(', ')}` };
95
108
  }
96
- const hint = missing.map(m => `/install ${m.name} to enable lint_check for ${m.kind}`).join(' · ');
97
- return { status: 'warn', label: `Linter (${missing.map(m => m.name).join(', ')}) not found`, hint };
109
+ // Honest install command per linter. Falls back to "install via your
110
+ // package manager" when there is no clean one-liner (e.g. cargo).
111
+ const hint = missing.map(m => m.install
112
+ ? `${m.bin}: ${m.install}`
113
+ : `install ${m.bin} for ${m.kind} support`
114
+ ).join(' · ');
115
+ return { status: 'warn', label: `Linter (${missing.map(m => m.bin).join(', ')}) not found`, hint };
98
116
  }
99
117
 
100
118
  const LINTERS = [
101
- ['ruff', 'python'],
102
- ['eslint', 'javascript'],
103
- ['tsc', 'typescript'],
104
- ['cargo', 'rust'],
119
+ { bin: 'ruff', kind: 'python', install: 'pip install ruff' },
120
+ { bin: 'eslint', kind: 'javascript', install: 'npm i -g eslint' },
121
+ { bin: 'tsc', kind: 'typescript', install: 'npm i -g typescript' },
122
+ // cargo ships with rustup; no clean one-liner — surface the warning
123
+ // without a misleading "/install" command.
124
+ { bin: 'cargo', kind: 'rust', install: null },
105
125
  ];
106
126
 
107
127
  function projectUses(cwd, kind) {
@@ -252,9 +272,7 @@ export async function runPreflight({ auth, cwd, version, silent = false } = {})
252
272
  write('\n' + header + '\n\n');
253
273
 
254
274
  const checks = [];
255
- checks.push(checkAuthToken(auth));
256
- checks.push(checkProviderKey(auth));
257
- checks.push(await checkBackend(auth));
275
+ checks.push(await checkAuthAndBackend(auth));
258
276
  checks.push(checkGit(cwd));
259
277
  checks.push(checkLinters(cwd));
260
278
  checks.push(checkProjectMap(cwd));
@@ -24,6 +24,7 @@ import { JsonlWriter } from '../core/jsonl-writer.mjs';
24
24
  import { createToolExecutor } from '../core/tool-executor.mjs';
25
25
  import { CheckpointManager } from '../core/checkpoints.mjs';
26
26
  import { runPreflight } from '../onboarding/preflight.mjs';
27
+ import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
27
28
  import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
28
29
  import {
29
30
  getVerbosity,
@@ -121,6 +122,7 @@ const session = {
121
122
  costBreakdown: [], // per-model usage: [{ model, role, input_tokens, output_tokens, cost }]
122
123
  totalCost: 0, // accumulated session cost (USD)
123
124
  costAccurate: false, // true if backend provides per-model breakdown
125
+ isByok: false, // set from session_info; hides cost + credits when true
124
126
  };
125
127
 
126
128
  // ── Commands ──
@@ -165,26 +167,15 @@ const COMMANDS = {
165
167
  // ── Banner ──
166
168
 
167
169
  function printBanner(auth) {
170
+ // Delegate the visual block to the branded banner module (PRD-055 §4.3,
171
+ // gradient KEPLER letters in Deep Space Purple → Stellar Magenta → Neon
172
+ // Cyan). The trailing status line stays here because it needs `auth`.
173
+ printBrandedBanner();
174
+
168
175
  const creds = auth.loadCredentials();
169
176
  const env = process.env.TARANG_ENV || 'production';
170
177
  const authStatus = creds.token ? c.green('authenticated') : c.red('/login to start');
171
-
172
- const CYAN = '\x1b[36m';
173
- const DIM = '\x1b[2m';
174
- const BOLD = '\x1b[1m';
175
- const YELLOW = '\x1b[33m';
176
- const RST = '\x1b[0m';
177
-
178
- process.stderr.write('\n');
179
- process.stderr.write(`${DIM} ✦${RST}\n`);
180
- process.stderr.write(`${DIM} ╭──────────────────────────╮${RST}\n`);
181
- process.stderr.write(`${DIM} │${RST} ${BOLD}${CYAN}K · E · P · L · E · R${RST} ${DIM}│${RST}\n`);
182
- process.stderr.write(`${DIM} ╰──────── ${YELLOW}◯${RST}${DIM} ───────────────╯${RST}\n`);
183
- process.stderr.write(`${DIM} ╱ ╲${RST}\n`);
184
- process.stderr.write(`${DIM} the agentic os${RST}\n`);
185
- process.stderr.write('\n');
186
- process.stderr.write(` ${c.gray('v' + VERSION)} ${c.dim(env)} ${authStatus}\n`);
187
- process.stderr.write('\n');
178
+ process.stderr.write(` ${c.gray('v' + VERSION)} ${c.dim(env)} ${authStatus}\n\n`);
188
179
  }
189
180
 
190
181
  // ── Prompt Chrome ──
@@ -212,12 +203,12 @@ function printBanner(auth) {
212
203
  */
213
204
  function buildContextStrip() {
214
205
  const totalTokens = session.inputTokens + session.outputTokens;
215
- const credits = formatCredits(costToCredits(session.totalCost));
216
206
  const elapsed = formatElapsed(session.startTime);
217
207
 
208
+ // BYOK: user pays the provider directly, suppress credits entirely.
218
209
  const right = [
219
210
  c.dim(`${formatTokens(totalTokens)} tok`),
220
- c.dim(credits),
211
+ ...(session.isByok ? [] : [c.dim(formatCredits(costToCredits(session.totalCost)))]),
221
212
  c.dim(elapsed),
222
213
  ].join(c.dim(' · '));
223
214
 
@@ -264,7 +255,7 @@ function printTurnSummary(toolCount, durationS, turnCost) {
264
255
  const parts = [];
265
256
  if (toolCount > 0) parts.push(`${toolCount} tools`);
266
257
  if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
267
- if (turnCost > 0) parts.push(formatCredits(costToCredits(turnCost)));
258
+ if (turnCost > 0 && !session.isByok) parts.push(formatCredits(costToCredits(turnCost)));
268
259
  if (parts.length > 0) {
269
260
  process.stderr.write(`\n ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
270
261
  }
@@ -281,12 +272,39 @@ function updateStatusBar() {
281
272
  * args. The result arrives later via `renderToolResult` and is appended as a
282
273
  * gutter line. Sub-agent calls are indented per session.inSubAgent.
283
274
  */
275
+ // Deferred-head strategy: we DON'T print the tool head when tool_call fires.
276
+ // Instead we buffer it and let renderToolResult emit one combined line
277
+ // "head → outcome · duration\n". A spinner shows what's running in the
278
+ // meantime so the user still has feedback during slow tools.
279
+ //
280
+ // If something else needs to print before the result arrives (a streamed
281
+ // content event, a sub-agent open, an error, completion), we flush the
282
+ // buffered head as a regular two-line shape first so the interleaving
283
+ // content lands below it.
284
+ let _pendingHead = null; // { callId, head, indent }
285
+
286
+ function flushPendingHead() {
287
+ if (!_pendingHead) return;
288
+ process.stderr.write(`\n${_pendingHead.head}\n`);
289
+ _pendingHead = null;
290
+ }
291
+
292
+ function clearPendingHead() {
293
+ // Called by interleaving handlers — flush as 2-line shape (because we are
294
+ // about to print something else) and continue.
295
+ flushPendingHead();
296
+ }
297
+
284
298
  function renderToolCall(data) {
285
299
  const tool = data?.tool || 'unknown';
286
300
  const args = data?.args || {};
287
301
  const indent = subAgentIndent();
288
302
  const callId = data?.call_id || data?._callId || `${tool}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
289
303
 
304
+ // If a previous head is still pending (no result yet), flush it as a
305
+ // regular two-line shape before starting the next one.
306
+ flushPendingHead();
307
+
290
308
  const head = formatCardHead(tool, args, {
291
309
  cwd: safeCwd(),
292
310
  columns: process.stderr.columns || 120,
@@ -295,7 +313,9 @@ function renderToolCall(data) {
295
313
 
296
314
  recordCard({ id: callId, tool, args, head, startedAt: Date.now() });
297
315
  session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
298
- process.stderr.write(`\n${head}\n`);
316
+ _pendingHead = { callId, head, indent };
317
+ // Spinner shows what's running until the result arrives.
318
+ startSpinner(`${tool}…`);
299
319
  }
300
320
 
301
321
  /**
@@ -314,7 +334,9 @@ function renderToolResult(data, eventType = 'tool_result') {
314
334
  const indent = subAgentIndent();
315
335
  const gutter = `${indent}${paint.text.dim('⎿')} `;
316
336
  const callId = data.call_id || data._callId;
317
- if (eventType === 'tool_done' && callId && _renderedToolResults.has(callId)) return;
337
+ // Either tool_result or tool_done is allowed to render — whichever wins
338
+ // the race. Subsequent events for the same callId are duplicates.
339
+ if (callId && _renderedToolResults.has(callId)) return;
318
340
  if (callId) _renderedToolResults.add(callId);
319
341
 
320
342
  const tool = data.tool || data._tool || '';
@@ -326,17 +348,44 @@ function renderToolResult(data, eventType = 'tool_result') {
326
348
  if (data._blocked) session.blockedOps++;
327
349
 
328
350
  const { text, tone: t } = summarizeResult(tool, data);
329
- const arrow = paint.text.dim('→');
351
+ // Em dash reads more like prose than a system arrow.
352
+ const arrow = paint.text.dim('—');
330
353
  const painter = t === 'success' ? paint.state.success
331
354
  : t === 'warn' ? paint.state.warn
332
355
  : t === 'danger' ? paint.state.danger
333
356
  : paint.text.dim;
334
- const duration = formatToolDuration(data);
357
+ // Skip the duration tail when the tool was effectively instant (<200ms)
358
+ // "1ms" / "0ms" was noise that hurt the prose feel.
359
+ const duration = (durationMs != null && durationMs < 200) ? '' : formatToolDuration(data);
335
360
  const tail = duration ? paint.text.dim(` · ${duration}`) : '';
336
- process.stderr.write(`${gutter}${arrow} ${painter(text || 'done')}${tail}\n`);
361
+ const outcome = `${arrow} ${painter(text || 'done')}${tail}`;
362
+ const hasLint = (tool === 'write_file' || tool === 'edit_file') && data.lint;
363
+
364
+ // ── Single-line combined emit ──
365
+ // If the head for this call is still buffered (no interleaving content
366
+ // landed), and the combined line fits the terminal width, emit ONE line
367
+ // and skip the gutter entirely.
368
+ if (_pendingHead && _pendingHead.callId === callId && !hasLint) {
369
+ const cols = process.stderr.columns || 120;
370
+ const combined = `${_pendingHead.head} ${outcome}`;
371
+ if (stripAnsi(combined).length <= cols) {
372
+ process.stderr.write(`\n${combined}\n`);
373
+ _pendingHead = null;
374
+ return;
375
+ }
376
+ // Combined too wide — flush the head as 2-line and fall through.
377
+ flushPendingHead();
378
+ } else if (_pendingHead) {
379
+ // Stale pending head (different callId) — flush it before printing this
380
+ // result's gutter line below.
381
+ flushPendingHead();
382
+ }
383
+
384
+ // Two-line shape: gutter under the (already-printed or just-flushed) head.
385
+ process.stderr.write(`${gutter}${outcome}\n`);
337
386
 
338
387
  // Lint warnings stay visible alongside writes.
339
- if ((tool === 'write_file' || tool === 'edit_file') && data.lint) {
388
+ if (hasLint) {
340
389
  process.stderr.write(`${gutter}${paint.state.warn('⚠ ' + String(data.lint).split('\n')[0].slice(0, 80))}\n`);
341
390
  }
342
391
  }
@@ -436,6 +485,9 @@ function startContentStream() {
436
485
 
437
486
  function appendContent(text) {
438
487
  if (!text) return;
488
+ // Any streamed content between renderToolCall and renderToolResult would
489
+ // scroll the head off "the line above", breaking the in-place collapse.
490
+ clearPendingHead();
439
491
  _streamBuffer += text;
440
492
  _streamedPartialText += text;
441
493
 
@@ -449,6 +501,9 @@ function flushContent() {
449
501
  if (!_streamBuffer) return;
450
502
 
451
503
  stopSpinner();
504
+ // Any buffered tool head needs to land BEFORE this content so the order
505
+ // is preserved on screen.
506
+ flushPendingHead();
452
507
  const rendered = renderMarkdown(_streamBuffer);
453
508
  for (const line of rendered.split('\n')) {
454
509
  process.stdout.write(` ${line}\n`);
@@ -478,6 +533,15 @@ function renderEvent(event) {
478
533
  case 'thinking': {
479
534
  const text = data?.message || data?.text || '';
480
535
  if (text && !text.startsWith('Processing')) {
536
+ // Surface substantive thinking text as visible prose so the user can
537
+ // follow the agent's reasoning, not just see a spinner blip. We
538
+ // print at most one line per distinct thought, dim italic.
539
+ if (text.length > 12 && text !== session._lastEmittedThinking) {
540
+ flushPendingHead();
541
+ stopSpinner();
542
+ process.stderr.write(` ${c.italic(c.dim(text.slice(0, 200)))}\n`);
543
+ session._lastEmittedThinking = text;
544
+ }
481
545
  startSpinner(text.slice(0, 80));
482
546
  // Capture reasoning so /why can replay it.
483
547
  session.lastReasoning = text;
@@ -621,6 +685,7 @@ function renderEvent(event) {
621
685
 
622
686
  case 'delegation': {
623
687
  stopSpinner();
688
+ clearPendingHead();
624
689
  const from = data?.from || '';
625
690
  const to = data?.to || '';
626
691
  session.delegations.push({ from, to, time: Date.now() });
@@ -636,6 +701,7 @@ function renderEvent(event) {
636
701
 
637
702
  case 'sub_agent_start': {
638
703
  stopSpinner();
704
+ clearPendingHead();
639
705
  const agentType = data?.type || 'sub-agent';
640
706
  const model = data?.model || '';
641
707
  const query = data?.query || '';
@@ -657,6 +723,7 @@ function renderEvent(event) {
657
723
 
658
724
  case 'sub_agent_complete': {
659
725
  stopSpinner();
726
+ clearPendingHead();
660
727
  const agentType = data?.type || 'sub-agent';
661
728
  const usage = data?.usage || {};
662
729
  const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
@@ -697,6 +764,9 @@ function renderEvent(event) {
697
764
  }
698
765
  if (data?.model) session.model = data.model;
699
766
  if (data?.user) session.user = { ...session.user, ...data.user };
767
+ // BYOK users pay their model provider directly; the platform does not
768
+ // charge them credits. Hide cost + credits when this flag is set.
769
+ if (typeof data?.is_byok === 'boolean') session.isByok = data.is_byok;
700
770
  break;
701
771
  }
702
772
 
@@ -773,8 +843,9 @@ function renderEvent(event) {
773
843
  success: successOverall,
774
844
  filesChanged: session.filesChanged,
775
845
  toolCounts: session.toolCounts,
776
- subAgents: { ...session.subAgentCounts, savedUsd: session.savedUsd },
777
- costUsd: turnCost || session.totalCost,
846
+ subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
847
+ // BYOK users pay their provider directly; suppress cost in the report.
848
+ costUsd: session.isByok ? null : (turnCost || session.totalCost),
778
849
  durationS: data?.duration_s,
779
850
  testsPass: data?.tests_passed != null
780
851
  ? { passed: data.tests_passed, total: data.tests_total || data.tests_passed }
@@ -799,6 +870,7 @@ function renderEvent(event) {
799
870
 
800
871
  case 'paused':
801
872
  stopSpinner();
873
+ flushPendingHead();
802
874
  process.stderr.write(` ${c.yellow('⏸')} Paused${data?.reason ? ' ' + c.dim(data.reason) : ''}\n`);
803
875
  break;
804
876
 
@@ -873,7 +945,11 @@ async function handleCommand(input, ctx) {
873
945
  process.stderr.write(` ${c.dim('Turns')} ${session.turns}\n`);
874
946
  process.stderr.write(` ${c.dim('Tools')} ${session.totalToolCalls} total, ${session.toolCalls} last turn\n`);
875
947
  process.stderr.write(` ${c.dim('Duration')} ${formatElapsed(session.startTime)}\n`);
876
- process.stderr.write(` ${c.dim('Credits')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
948
+ if (session.isByok) {
949
+ process.stderr.write(` ${c.dim('Billing')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
950
+ } else {
951
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
952
+ }
877
953
  process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
878
954
 
879
955
  // Permissions
@@ -941,12 +1017,20 @@ async function handleCommand(input, ctx) {
941
1017
  process.stderr.write(` ${c.gray('Turns:')} ${session.turns}\n`);
942
1018
  process.stderr.write(` ${c.gray('Tools:')} ${session.toolCalls}\n`);
943
1019
  process.stderr.write(` ${c.gray('Blocked:')} ${session.blockedOps}\n`);
944
- process.stderr.write(` ${c.gray('Credits:')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
1020
+ if (session.isByok) {
1021
+ process.stderr.write(` ${c.gray('Billing:')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
1022
+ } else {
1023
+ process.stderr.write(` ${c.gray('Credits:')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
1024
+ }
945
1025
  process.stderr.write(` ${c.gray('Elapsed:')} ${formatElapsed(session.startTime)}\n\n`);
946
1026
  return;
947
1027
  }
948
1028
 
949
1029
  case '/cost': {
1030
+ if (session.isByok) {
1031
+ process.stderr.write(`\n ${c.bold('Billing')} ${c.green('BYOK')} ${c.dim('— you pay your model provider directly. Kepler does not charge credits for BYOK usage.')}\n\n`);
1032
+ return;
1033
+ }
950
1034
  process.stderr.write(`\n ${c.bold('Session Credits')} ${c.brand(formatCredits(costToCredits(session.totalCost)))}`);
951
1035
  if (!session.costAccurate) {
952
1036
  process.stderr.write(` ${c.yellow('(estimated)')}`);
@@ -1065,8 +1149,8 @@ async function handleCommand(input, ctx) {
1065
1149
  success: true,
1066
1150
  filesChanged: session.filesChanged,
1067
1151
  toolCounts: session.toolCounts,
1068
- subAgents: { ...session.subAgentCounts, savedUsd: session.savedUsd },
1069
- costUsd: session.totalCost,
1152
+ subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
1153
+ costUsd: session.isByok ? null : session.totalCost,
1070
1154
  durationS: (Date.now() - session.startTime) / 1000,
1071
1155
  nextActions: ['/commit', '/pr', '/undo'],
1072
1156
  };
@@ -1378,19 +1462,9 @@ export async function startTerminalRepl() {
1378
1462
 
1379
1463
  const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints };
1380
1464
 
1381
- // ── Mission Control orbit + status bar ──
1382
- // Opt-out via KEPLER_STATUS_BAR=0 (debugging) or KEPLER_PLAIN=1 (PRD-055).
1383
- // status-bar.mjs already no-ops when stdout is not a TTY, but the explicit
1384
- // env opt-out is useful for debugging escape-sequence noise.
1385
- const statusBarEnabled = process.env.KEPLER_STATUS_BAR !== '0' && term().isTTY && !term().plain;
1386
- if (statusBarEnabled) {
1387
- _orbit = createOrbit();
1388
- attachOrbit(_orbit);
1389
- // Always unmount before exit so the terminal scroll region is restored.
1390
- process.on('beforeExit', unmountStatusBar);
1391
- process.on('exit', unmountStatusBar);
1392
- }
1393
-
1465
+ // ── Print banner + preflight + init BEFORE mounting the status bar ──
1466
+ // The status bar shrinks the scroll region; if it mounts first, the
1467
+ // banner scrolls off-screen before the user ever sees it.
1394
1468
  printBanner(auth);
1395
1469
 
1396
1470
  // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
@@ -1437,12 +1511,41 @@ export async function startTerminalRepl() {
1437
1511
 
1438
1512
  process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
1439
1513
 
1440
- const PROMPT = `${c.brand('kepler')} ${c.dim('›')} `;
1514
+ // Mission Control status bar is OPT-IN as of v2.0.1.
1515
+ // Set KEPLER_STATUS_BAR=1 (or KEPLER_MISSION=1) to enable the persistent
1516
+ // bottom-anchored ORBIT bar. Default off because the DECSTBM scroll
1517
+ // region was eating the prompt visibility on some terminals (issue
1518
+ // observed during v2.0.0 testing). The orbit state machine and tool
1519
+ // cards still work without the bar — the bar is just the rendering.
1520
+ const statusBarEnabled = (
1521
+ process.env.KEPLER_STATUS_BAR === '1' || process.env.KEPLER_MISSION === '1'
1522
+ ) && term().isTTY && !term().plain;
1523
+ if (statusBarEnabled) {
1524
+ _orbit = createOrbit();
1525
+ attachOrbit(_orbit);
1526
+ process.on('beforeExit', unmountStatusBar);
1527
+ process.on('exit', unmountStatusBar);
1528
+ }
1529
+
1530
+ // The prompt label is the USER speaking, not the agent. Use the signed-in
1531
+ // GitHub handle if known, otherwise fall back to "You".
1532
+ //
1533
+ // readline counts every byte of the prompt as a visible column when it
1534
+ // computes cursor position for line-wrapping; ANSI color codes throw the
1535
+ // math off and produce duplicated text on wrap. Wrap each escape sequence
1536
+ // in SOH (\x01) ... STX (\x02) so readline skips it when measuring width.
1537
+ function rlSafe(s) {
1538
+ return String(s || '').replace(/\x1b\[[0-9;]*m/g, '\x01$&\x02');
1539
+ }
1540
+ function userPrompt() {
1541
+ const who = session.user?.github_username || session.user?.email?.split('@')[0] || 'You';
1542
+ return rlSafe(`${c.brand(who)} ${c.dim('›')} `);
1543
+ }
1441
1544
 
1442
1545
  const rl = readline.createInterface({
1443
1546
  input: process.stdin,
1444
1547
  output: process.stderr,
1445
- prompt: PROMPT,
1548
+ prompt: userPrompt(),
1446
1549
  completer: (line) => {
1447
1550
  if (line.startsWith('/')) {
1448
1551
  const hits = Object.keys(COMMANDS).filter(cmd => cmd.startsWith(line));
@@ -1461,6 +1564,7 @@ export async function startTerminalRepl() {
1461
1564
  function showPrompt() {
1462
1565
  printPromptBlock();
1463
1566
  process.stderr.write('\n'); // half-inch vertical gap above input line
1567
+ rl.setPrompt(userPrompt()); // refresh label in case session.user resolved
1464
1568
  rl.prompt();
1465
1569
  }
1466
1570
 
@@ -1497,6 +1601,7 @@ export async function startTerminalRepl() {
1497
1601
  session.toolCounts = {};
1498
1602
  session.subAgentCounts = {};
1499
1603
  session.savedUsd = 0;
1604
+ session._lastEmittedThinking = '';
1500
1605
 
1501
1606
  // Tell the orbit a new turn started — switches to DISCOVERY and updates
1502
1607
  // task / turn counters in the status bar.
@@ -1555,7 +1660,10 @@ export async function startTerminalRepl() {
1555
1660
  // Esc key (single byte 0x1b, not part of arrow sequence)
1556
1661
  if (bytes.length === 1 && bytes[0] === 0x1b) {
1557
1662
  stopSpinner();
1558
- process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelling...')}\n`);
1663
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelled.')}\n`);
1664
+ // cancel() now aborts the in-flight SSE reader; the for-await loop
1665
+ // wakes up immediately and the prompt returns. No more "stuck"
1666
+ // Cancelling… message.
1559
1667
  client.cancel();
1560
1668
  return;
1561
1669
  }
@@ -1,30 +1,33 @@
1
+ // Present-progressive verbs — read more conversationally than "Read file":
2
+ // "Reading auth.py — 47 lines" reads like the agent narrating, not a log.
1
3
  const TOOL_LABELS = Object.freeze({
2
- shell: 'Run command',
3
- read_file: 'Read file',
4
- read_files: 'Read files',
5
- write_file: 'Create file',
6
- write_project: 'Create project files',
7
- edit_file: 'Edit file',
8
- delete_file: 'Delete file',
9
- list_files: 'List files',
10
- search_code: 'Search code',
11
- search_files: 'Search files',
12
- grep: 'Search text',
13
- get_file_info: 'Inspect file',
14
- validate_file: 'Validate file',
15
- validate_build: 'Validate build',
16
- validate_structure: 'Check project structure',
17
- lint_check: 'Check code quality',
18
- run_tests: 'Run tests',
19
- git_diff: 'Review changes',
20
- git_status: 'Check repository status',
21
- analyze_code: 'Analyze code',
22
- explore: 'Explore codebase',
23
- plan: 'Create implementation plan',
24
- verify: 'Verify implementation',
25
- debug: 'Debug issue',
26
- refactor: 'Refactor code',
27
- ask_user: 'Ask for input',
4
+ shell: 'Running',
5
+ read_file: 'Reading',
6
+ read_files: 'Reading',
7
+ write_file: 'Writing',
8
+ write_project: 'Writing files',
9
+ edit_file: 'Editing',
10
+ delete_file: 'Deleting',
11
+ list_files: 'Listing',
12
+ search_code: 'Searching',
13
+ search_files: 'Searching files',
14
+ grep: 'Searching for',
15
+ get_file_info: 'Inspecting',
16
+ validate_file: 'Validating',
17
+ validate_build: 'Validating build',
18
+ validate_structure: 'Checking structure',
19
+ lint_check: 'Linting',
20
+ run_tests: 'Running tests',
21
+ git_diff: 'Reviewing changes',
22
+ git_status: 'Checking git',
23
+ analyze_code: 'Analyzing',
24
+ get_project_overview: 'Indexing project',
25
+ explore: 'Exploring',
26
+ plan: 'Planning',
27
+ verify: 'Verifying',
28
+ debug: 'Debugging',
29
+ refactor: 'Refactoring',
30
+ ask_user: 'Asking',
28
31
  });
29
32
 
30
33
  export function toolDisplayLabel(tool) {