compact-agent 1.5.0 → 1.6.0

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.
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { buildCommitPrompt, buildPRPrompt, printDiff, printLog } from './git-wor
20
20
  import { buildReviewPrompt, buildTDDPrompt, buildSecurityReviewPrompt, runAudit, printAuditReport, buildPlanPrompt, buildE2EPrompt, buildBuildFixPrompt, buildEvalPrompt } from './evaluation.js';
21
21
  import { printRules } from './rules.js';
22
22
  import { buildOrchestrationPrompt } from './orchestration.js';
23
- import { printBanner as printThemedBanner, printSplash, theme, sym } from './theme.js';
23
+ import { printBanner as printThemedBanner, theme, sym, formatDuration, installScreenReaderDispatch, uninstallScreenReaderDispatch } from './theme.js';
24
24
  import { saveExport } from './export.js';
25
25
  // New feature modules
26
26
  import { buildVerifyPrompt, saveCheckpoint, listCheckpoints } from './verification.js';
@@ -55,6 +55,10 @@ import { installEcc, getEccCommandPrompt, loadEccState, eccResourcesAvailable, }
55
55
  import { buildWalkthroughPrompt } from './walkthrough.js';
56
56
  // Stitch (Google's AI UI/UX design tool) — /stitch, /stitch-config, /stitch-tools
57
57
  import { buildStitchPrompt, buildStitchToolsPrompt, saveStitchConfig, printStitchStatus, stitchConfigured } from './stitch.js';
58
+ // Voice / accessibility — built-in dictation (Whisper) + readout (ElevenLabs)
59
+ import { printVoiceStatus, isVoiceEnabled, getTtsConfig, getSttConfig, getAccessibilityConfig, speak, dictateOnce, } from './voice.js';
60
+ import { isFfmpegAvailable, audioCue, startRecording } from './audio.js';
61
+ import { applyScreenReader } from './accessibility.js';
58
62
  /**
59
63
  * Unified prompt resolver — prefers the bundled ECC prompt for a given
60
64
  * intent and falls back to the built-in builder when ECC isn't installed.
@@ -253,6 +257,17 @@ export function handleSlashCommand(input, config, messages, session, mode) {
253
257
  // database-migration, add-language-rules) auto-inject when you describe
254
258
  // matching work — no slash command needed. Status line below confirms
255
259
  // it's enabled.
260
+ console.log(h('\n ── Voice & accessibility ──'));
261
+ console.log(d(' ') + c('/voice') + d(' — show voice config & status (off by default)'));
262
+ console.log(d(' ') + c('/voice on|off') + d(' — master switch for dictation + readout'));
263
+ console.log(d(' ') + c('/voice config') + d(' — quick setup walkthrough'));
264
+ console.log(d(' ') + c('/voice key stt <key>') + d(' — OpenAI key for Whisper dictation'));
265
+ console.log(d(' ') + c('/voice key tts <key>') + d(' — ElevenLabs key for assistant readout'));
266
+ console.log(d(' ') + c('/voice test') + d(' — play a short test utterance'));
267
+ console.log(d(' ') + c('/voice echo|skip-code|speed') + d(' — fine-tune behavior'));
268
+ console.log(d(' ') + c('/dictate [s]') + d(' — one-shot record + transcribe (default 30s)'));
269
+ console.log(d(' ') + c('/accessibility') + d(' — toggle screen-reader mode, audio cues, destructive-confirm'));
270
+ console.log(d(' Hotkeys: F1 dictate · F2 pause · F3 replay · F4 skip · F5 speed+ · F6 speed–'));
256
271
  console.log(h('\n ── Stitch (Google AI UI/UX design) ──'));
257
272
  console.log(d(' Use ') + c('/mode design') + d(' or ') + c('/design <task>') + d(' for UI work — the agent uses Stitch automatically.'));
258
273
  console.log(d(' ') + c('/stitch') + d(' — show config status'));
@@ -334,6 +349,15 @@ export function handleSlashCommand(input, config, messages, session, mode) {
334
349
  mode.current = args;
335
350
  const m = MODES[mode.current];
336
351
  console.log(chalk.green(` Mode: ${m.label} — ${m.description}`));
352
+ // Accessibility: speak the mode-switch when configured. Doesn't
353
+ // block — fire-and-forget. Errors swallowed (voice should never
354
+ // break the REPL).
355
+ if (isVoiceEnabled(config) && getAccessibilityConfig(config).announceModeSwitches) {
356
+ const tts = getTtsConfig(config);
357
+ if (tts.apiKey) {
358
+ speak(`Mode switched to ${m.label}`, config, { voiceId: tts.assistantVoiceId }).catch(() => { });
359
+ }
360
+ }
337
361
  }
338
362
  else if (args) {
339
363
  console.log(chalk.yellow(` Unknown mode: ${args}`));
@@ -1074,6 +1098,206 @@ export function handleSlashCommand(input, config, messages, session, mode) {
1074
1098
  console.log(chalk.dim(' Restart the REPL for the tool to appear in /tools.'));
1075
1099
  return { handled: true };
1076
1100
  }
1101
+ // ── Voice / accessibility ────────────────────────
1102
+ // /voice — show current voice config + status
1103
+ // /voice on | off — master switch
1104
+ // /voice config — interactive setup (asks for keys)
1105
+ // /voice test — synth a short test utterance to verify TTS
1106
+ // /voice key stt <KEY> — set OpenAI key for Whisper STT only
1107
+ // /voice key tts <KEY> — set ElevenLabs key for TTS only
1108
+ // /voice echo on | off — toggle TTS-echo of user input
1109
+ // /voice skip-code on|off — toggle stripping code blocks from TTS
1110
+ // /voice speed <n> — set 0.5..2.0
1111
+ case '/voice': {
1112
+ const parts = args.trim().split(/\s+/).filter(Boolean);
1113
+ const sub = (parts[0] || '').toLowerCase();
1114
+ if (!sub) {
1115
+ printVoiceStatus(config);
1116
+ return { handled: true };
1117
+ }
1118
+ if (sub === 'on' || sub === 'off') {
1119
+ config.voice = config.voice || {};
1120
+ config.voice.enabled = sub === 'on';
1121
+ saveConfig(config);
1122
+ console.log(chalk.green(` Voice: ${sub === 'on' ? 'ON' : 'OFF'}`));
1123
+ if (sub === 'on') {
1124
+ if (!getTtsConfig(config).apiKey) {
1125
+ console.log(chalk.yellow(' ⚠ No ElevenLabs key set. Run /voice key tts <KEY> to enable readout.'));
1126
+ }
1127
+ if (!getSttConfig(config).apiKey) {
1128
+ console.log(chalk.yellow(' ⚠ No OpenAI key for Whisper. Run /voice key stt <KEY> to enable dictation.'));
1129
+ }
1130
+ isFfmpegAvailable().then((ok) => {
1131
+ if (!ok)
1132
+ console.log(chalk.yellow(' ⚠ ffmpeg not found on PATH. Install ffmpeg: https://ffmpeg.org/'));
1133
+ });
1134
+ }
1135
+ return { handled: true };
1136
+ }
1137
+ if (sub === 'config') {
1138
+ // Lightweight interactive setup deferred to the prompt — user can
1139
+ // also just use `/voice key stt ...` and `/voice key tts ...`.
1140
+ console.log(chalk.cyan('\n /voice config — quick setup'));
1141
+ console.log(chalk.dim(' 1. Get an OpenAI key for Whisper STT: https://platform.openai.com/api-keys'));
1142
+ console.log(chalk.dim(' 2. Get an ElevenLabs key for TTS: https://elevenlabs.io/app/settings/api-keys'));
1143
+ console.log(chalk.dim(' 3. Run: /voice key stt <openai-key>'));
1144
+ console.log(chalk.dim(' /voice key tts <elevenlabs-key>'));
1145
+ console.log(chalk.dim(' /voice on'));
1146
+ console.log(chalk.dim(' 4. Press F1 to dictate, hear assistant readout automatically.'));
1147
+ console.log();
1148
+ return { handled: true };
1149
+ }
1150
+ if (sub === 'key') {
1151
+ const target = (parts[1] || '').toLowerCase();
1152
+ const key = parts.slice(2).join(' ').trim();
1153
+ if ((target !== 'stt' && target !== 'tts') || !key) {
1154
+ console.log(chalk.yellow(' Usage: /voice key stt <openai-key> | /voice key tts <elevenlabs-key>'));
1155
+ return { handled: true };
1156
+ }
1157
+ config.voice = config.voice || {};
1158
+ if (target === 'stt') {
1159
+ config.voice.stt = { ...(config.voice.stt || {}), apiKey: key };
1160
+ console.log(chalk.green(` STT key saved (***${key.slice(-4)}).`));
1161
+ }
1162
+ else {
1163
+ config.voice.tts = { ...(config.voice.tts || {}), apiKey: key };
1164
+ console.log(chalk.green(` TTS key saved (***${key.slice(-4)}).`));
1165
+ }
1166
+ saveConfig(config);
1167
+ return { handled: true };
1168
+ }
1169
+ if (sub === 'test') {
1170
+ const tts = getTtsConfig(config);
1171
+ if (!tts.apiKey) {
1172
+ console.log(chalk.yellow(' No TTS key. Run /voice key tts <elevenlabs-key> first.'));
1173
+ return { handled: true };
1174
+ }
1175
+ console.log(chalk.dim(' Synthesizing test utterance…'));
1176
+ speak('Voice readout is working. This is the assistant voice.', config, { voiceId: tts.assistantVoiceId })
1177
+ .then((ok) => console.log(ok ? chalk.green(' ✓ Played.') : chalk.yellow(' ✗ Playback failed — check ffmpeg.')));
1178
+ return { handled: true };
1179
+ }
1180
+ if (sub === 'echo') {
1181
+ const v = (parts[1] || '').toLowerCase();
1182
+ if (v !== 'on' && v !== 'off') {
1183
+ console.log(chalk.yellow(' Usage: /voice echo on|off'));
1184
+ return { handled: true };
1185
+ }
1186
+ config.voice = config.voice || {};
1187
+ config.voice.tts = { ...(config.voice.tts || {}), echoUser: v === 'on' };
1188
+ saveConfig(config);
1189
+ console.log(chalk.green(` User-echo: ${v.toUpperCase()}`));
1190
+ return { handled: true };
1191
+ }
1192
+ if (sub === 'skip-code') {
1193
+ const v = (parts[1] || '').toLowerCase();
1194
+ if (v !== 'on' && v !== 'off') {
1195
+ console.log(chalk.yellow(' Usage: /voice skip-code on|off'));
1196
+ return { handled: true };
1197
+ }
1198
+ config.voice = config.voice || {};
1199
+ config.voice.tts = { ...(config.voice.tts || {}), skipCode: v === 'on' };
1200
+ saveConfig(config);
1201
+ console.log(chalk.green(` Skip-code: ${v.toUpperCase()}`));
1202
+ return { handled: true };
1203
+ }
1204
+ if (sub === 'speed') {
1205
+ const n = parseFloat(parts[1] || '');
1206
+ if (isNaN(n) || n < 0.25 || n > 4.0) {
1207
+ console.log(chalk.yellow(' Usage: /voice speed <0.25..4.0>'));
1208
+ return { handled: true };
1209
+ }
1210
+ config.voice = config.voice || {};
1211
+ config.voice.tts = { ...(config.voice.tts || {}), speed: n };
1212
+ saveConfig(config);
1213
+ console.log(chalk.green(` TTS speed: ${n}x`));
1214
+ return { handled: true };
1215
+ }
1216
+ console.log(chalk.yellow(` Unknown /voice subcommand: ${sub}`));
1217
+ console.log(chalk.dim(' Try: on, off, config, test, key, echo, skip-code, speed'));
1218
+ return { handled: true };
1219
+ }
1220
+ // /dictate — one-shot push-to-talk WITHOUT the F1 hotkey, useful when a
1221
+ // user is testing the pipeline or running under a terminal that strips
1222
+ // function keys. Records up to 30s, transcribes, injects as next prompt.
1223
+ case '/dictate': {
1224
+ const maxSec = parseInt(args, 10) || 30;
1225
+ console.log(chalk.dim(` /dictate — recording up to ${maxSec}s…`));
1226
+ // Return as an async-injected prompt; we resolve the recording
1227
+ // synchronously here for simplicity (REPL is blocking anyway).
1228
+ return { handled: true, injectPrompt: '__DICTATE__' + maxSec };
1229
+ }
1230
+ // /accessibility — show or toggle the accessibility sub-block
1231
+ // /accessibility — print status
1232
+ // /accessibility screen-reader on|off
1233
+ // /accessibility cues on|off
1234
+ // /accessibility announce-errors on|off
1235
+ // /accessibility announce-modes on|off
1236
+ // /accessibility confirm-destructive on|off
1237
+ // /accessibility long-resp <words>
1238
+ case '/accessibility':
1239
+ case '/a11y': {
1240
+ const parts = args.trim().split(/\s+/).filter(Boolean);
1241
+ const sub = (parts[0] || '').toLowerCase();
1242
+ const v = (parts[1] || '').toLowerCase();
1243
+ if (!sub) {
1244
+ printVoiceStatus(config);
1245
+ return { handled: true };
1246
+ }
1247
+ const setBool = (field, label) => {
1248
+ if (v !== 'on' && v !== 'off') {
1249
+ console.log(chalk.yellow(` Usage: /accessibility ${sub} on|off`));
1250
+ return;
1251
+ }
1252
+ config.voice = config.voice || {};
1253
+ config.voice.accessibility = { ...(config.voice.accessibility || {}), [field]: v === 'on' };
1254
+ saveConfig(config);
1255
+ // Screen-reader mode is special: install/uninstall the stdout filter
1256
+ // immediately so the toggle takes effect for the very next log line.
1257
+ if (field === 'screenReader') {
1258
+ if (v === 'on')
1259
+ installScreenReaderDispatch(applyScreenReader);
1260
+ else
1261
+ uninstallScreenReaderDispatch();
1262
+ }
1263
+ console.log(chalk.green(` ${label}: ${v.toUpperCase()}`));
1264
+ };
1265
+ if (sub === 'screen-reader' || sub === 'screenreader' || sub === 'sr') {
1266
+ setBool('screenReader', 'Screen-reader mode');
1267
+ return { handled: true };
1268
+ }
1269
+ if (sub === 'cues' || sub === 'audio-cues') {
1270
+ setBool('audioCues', 'Audio cues');
1271
+ return { handled: true };
1272
+ }
1273
+ if (sub === 'announce-errors' || sub === 'errors') {
1274
+ setBool('announceErrors', 'Announce errors');
1275
+ return { handled: true };
1276
+ }
1277
+ if (sub === 'announce-modes' || sub === 'modes') {
1278
+ setBool('announceModeSwitches', 'Announce mode switches');
1279
+ return { handled: true };
1280
+ }
1281
+ if (sub === 'confirm-destructive' || sub === 'destructive') {
1282
+ setBool('askBeforeDestructive', 'Ask before destructive');
1283
+ return { handled: true };
1284
+ }
1285
+ if (sub === 'long-resp' || sub === 'threshold') {
1286
+ const n = parseInt(parts[1] || '', 10);
1287
+ if (!n || n < 50) {
1288
+ console.log(chalk.yellow(' Usage: /accessibility long-resp <words≥50>'));
1289
+ return { handled: true };
1290
+ }
1291
+ config.voice = config.voice || {};
1292
+ config.voice.accessibility = { ...(config.voice.accessibility || {}), longResponseThreshold: n };
1293
+ saveConfig(config);
1294
+ console.log(chalk.green(` Long-response threshold: ${n} words`));
1295
+ return { handled: true };
1296
+ }
1297
+ console.log(chalk.yellow(` Unknown /accessibility subcommand: ${sub}`));
1298
+ console.log(chalk.dim(' Try: screen-reader, cues, announce-errors, announce-modes, confirm-destructive, long-resp'));
1299
+ return { handled: true };
1300
+ }
1077
1301
  // ── ECC (everything-claude-code) — no slash commands ───
1078
1302
  // ECC is bundled, free, auto-installed on first launch, and used
1079
1303
  // automatically: built-in commands (/tdd /review /security-review /plan
@@ -1128,6 +1352,12 @@ async function main() {
1128
1352
  else {
1129
1353
  config = loadConfig();
1130
1354
  }
1355
+ // Install the screen-reader output filter if the user's config has it on.
1356
+ // Done as early as possible so every subsequent console.log (banner, hooks,
1357
+ // ECC install report, etc.) gets the filter applied uniformly.
1358
+ if (config.voice?.accessibility?.screenReader) {
1359
+ installScreenReaderDispatch(applyScreenReader);
1360
+ }
1131
1361
  // Create session
1132
1362
  const mode = { current: 'dev' };
1133
1363
  const session = createSession(process.cwd(), config.model, config.provider, mode.current);
@@ -1141,8 +1371,8 @@ async function main() {
1141
1371
  // Show startup display based on theme setting
1142
1372
  const themeMode = config.theme || 'full';
1143
1373
  if (themeMode === 'full') {
1144
- // Full mode: splash + banner
1145
- printSplash();
1374
+ // Full mode: banner. ASCII splash removed per user request — both `full`
1375
+ // and `compact` themes now render the same banner block.
1146
1376
  printThemedBanner(config.provider, config.model, mode.current, config.permissionMode, session.id, ALL_TOOLS.map((t) => t.name));
1147
1377
  }
1148
1378
  else if (themeMode === 'compact') {
@@ -1155,12 +1385,172 @@ async function main() {
1155
1385
  console.log('');
1156
1386
  }
1157
1387
  let autoRoute = false;
1388
+ // ── F-key hotkey listener ────────────────────────────────
1389
+ // Voice / accessibility hotkeys. All keys are no-ops when voice is off, so
1390
+ // installing the listener unconditionally is safe and lets the user enable
1391
+ // voice mid-session without restarting.
1392
+ //
1393
+ // F1 = push-to-talk dictation (toggle: first press starts, second stops)
1394
+ // F2 = pause / resume current TTS playback
1395
+ // F3 = replay last TTS chunk
1396
+ // F4 = skip current TTS chunk
1397
+ // F5 = speed up TTS (×1.25)
1398
+ // F6 = slow down TTS (×0.8)
1399
+ //
1400
+ // We listen on the raw keypress stream. readline.emitKeypressEvents wires
1401
+ // every keystroke into 'keypress' events; we filter to F-keys only and
1402
+ // pass everything else through to readline's normal line-buffered reader.
1403
+ let dictateController = null;
1404
+ let dictateActive = false;
1405
+ let activePlaybackCtl = null;
1406
+ let lastSpokenChunk = null;
1407
+ // Track aborts so query.ts can register a fresh playback session
1408
+ globalThis.__voicePlaybackCtl = null;
1409
+ globalThis.__voiceLastChunk = null;
1410
+ // emitKeypressEvents sets up the keypress event source; we don't put stdin
1411
+ // in raw mode (readline does that as needed). Some platforms / terminals
1412
+ // don't deliver F-keys at all — failure here is a silent no-op.
1413
+ try {
1414
+ // emitKeypressEvents lives on the callback-flavor 'node:readline' module
1415
+ // (the promises variant doesn't expose it). Dynamically import it so we
1416
+ // don't add another top-level import.
1417
+ const readlineCb = await import('node:readline');
1418
+ readlineCb.emitKeypressEvents(stdin);
1419
+ stdin.on('keypress', (_str, key) => {
1420
+ if (!key)
1421
+ return;
1422
+ const name = String(key.name || '').toLowerCase();
1423
+ // Only intercept F1-F6; everything else falls through to readline
1424
+ if (!['f1', 'f2', 'f3', 'f4', 'f5', 'f6'].includes(name))
1425
+ return;
1426
+ if (!isVoiceEnabled(config)) {
1427
+ // Voice off — only show a one-time hint
1428
+ return;
1429
+ }
1430
+ const a = getAccessibilityConfig(config);
1431
+ if (name === 'f1') {
1432
+ // Push-to-talk toggle
1433
+ if (dictateActive) {
1434
+ dictateActive = false;
1435
+ const ctl = dictateController;
1436
+ dictateController = null;
1437
+ if (!ctl)
1438
+ return;
1439
+ (async () => {
1440
+ if (a.audioCues)
1441
+ await audioCue('recording-stop');
1442
+ const buf = await ctl.stop();
1443
+ if (!buf) {
1444
+ console.log(chalk.dim(' [F1] no audio captured.'));
1445
+ return;
1446
+ }
1447
+ if (a.audioCues)
1448
+ await audioCue('processing');
1449
+ const { transcribeAudio } = await import('./voice.js');
1450
+ const transcript = await transcribeAudio(buf, config, 'wav');
1451
+ if (!transcript) {
1452
+ console.log(chalk.dim(' [F1] transcription failed.'));
1453
+ if (a.audioCues)
1454
+ await audioCue('error');
1455
+ return;
1456
+ }
1457
+ if (a.audioCues)
1458
+ await audioCue('done');
1459
+ // Hand the transcript to readline's input buffer. Most reliable
1460
+ // way is to print the transcript and let the user press Enter,
1461
+ // or — when autoSubmit is on — synthesize a newline so the
1462
+ // current rl.question() resolves with the text.
1463
+ const stt = getSttConfig(config);
1464
+ // We can't directly fill rl's buffer from outside; instead, we
1465
+ // write the transcript and a newline to stdin, which readline
1466
+ // picks up the same as if the user had typed it.
1467
+ stdin.write(transcript);
1468
+ if (stt.autoSubmit)
1469
+ stdin.write('\n');
1470
+ })();
1471
+ }
1472
+ else {
1473
+ // Start recording
1474
+ (async () => {
1475
+ const ok = await isFfmpegAvailable();
1476
+ if (!ok) {
1477
+ console.log(chalk.yellow(' [F1] ffmpeg not on PATH. Install ffmpeg to dictate.'));
1478
+ return;
1479
+ }
1480
+ const ctl = await startRecording(60);
1481
+ if (!ctl) {
1482
+ console.log(chalk.yellow(' [F1] could not start mic capture.'));
1483
+ return;
1484
+ }
1485
+ dictateController = ctl;
1486
+ dictateActive = true;
1487
+ if (a.audioCues)
1488
+ await audioCue('recording-start');
1489
+ console.log(chalk.dim(' [F1] recording — press F1 again to stop.'));
1490
+ })();
1491
+ }
1492
+ return;
1493
+ }
1494
+ if (name === 'f2') {
1495
+ // Pause / resume — implement as cancel current chunk; replay restarts.
1496
+ const g = globalThis;
1497
+ if (g.__voicePlaybackCtl && !g.__voicePlaybackCtl.signal.aborted) {
1498
+ g.__voicePlaybackCtl.abort();
1499
+ console.log(chalk.dim(' [F2] TTS paused.'));
1500
+ }
1501
+ return;
1502
+ }
1503
+ if (name === 'f3') {
1504
+ // Replay last chunk
1505
+ const g = globalThis;
1506
+ const chunk = g.__voiceLastChunk;
1507
+ if (!chunk) {
1508
+ console.log(chalk.dim(' [F3] nothing to replay.'));
1509
+ return;
1510
+ }
1511
+ const tts = getTtsConfig(config);
1512
+ if (!tts.apiKey)
1513
+ return;
1514
+ (async () => {
1515
+ await speak(chunk, config, { voiceId: tts.assistantVoiceId });
1516
+ })();
1517
+ return;
1518
+ }
1519
+ if (name === 'f4') {
1520
+ // Skip = same as pause for now (chunked playback boundaries)
1521
+ const g = globalThis;
1522
+ if (g.__voicePlaybackCtl)
1523
+ g.__voicePlaybackCtl.abort();
1524
+ console.log(chalk.dim(' [F4] TTS skipped.'));
1525
+ return;
1526
+ }
1527
+ if (name === 'f5' || name === 'f6') {
1528
+ config.voice = config.voice || {};
1529
+ const tts = config.voice.tts = { ...(config.voice.tts || {}) };
1530
+ const cur = tts.speed ?? 1.0;
1531
+ const next = name === 'f5' ? Math.min(2.0, cur * 1.25) : Math.max(0.5, cur * 0.8);
1532
+ tts.speed = Math.round(next * 100) / 100;
1533
+ saveConfig(config);
1534
+ console.log(chalk.dim(` [${name.toUpperCase()}] TTS speed: ${tts.speed}x`));
1535
+ return;
1536
+ }
1537
+ });
1538
+ }
1539
+ catch {
1540
+ // No keypress support — accessibility users can still use /dictate.
1541
+ }
1542
+ // Session-start anchor — used by the [Nm Ns] tag prepended to every prompt
1543
+ // so the user can see at a glance how long the REPL has been open. Combined
1544
+ // with the per-chain timer printed after each model response (see runQuery),
1545
+ // gives both "how long am I here" and "how long was that last response."
1546
+ const sessionStartMs = new Date(session.createdAt).getTime();
1158
1547
  // Main REPL loop
1159
1548
  while (true) {
1160
1549
  let input;
1161
1550
  try {
1551
+ const sessionTag = theme.dim(`[${formatDuration(Date.now() - sessionStartMs)}] `);
1162
1552
  const modeTag = mode.current !== 'dev' ? theme.dim(`[${mode.current}] `) : '';
1163
- input = await rl.question(modeTag + theme.prompt(`${sym.prompt} `));
1553
+ input = await rl.question(sessionTag + modeTag + theme.prompt(`${sym.prompt} `));
1164
1554
  }
1165
1555
  catch {
1166
1556
  break;
@@ -1205,7 +1595,22 @@ async function main() {
1205
1595
  }
1206
1596
  // Some commands inject a prompt into the conversation (e.g. /commit, /review, /tdd)
1207
1597
  if (result.injectPrompt) {
1208
- messages.push({ role: 'user', content: result.injectPrompt });
1598
+ // Special-case the /dictate flow: synthesize the prompt from the mic
1599
+ // before pushing it as a user message. We use the sentinel
1600
+ // "__DICTATE__<seconds>" so the slash handler stays purely sync.
1601
+ if (result.injectPrompt.startsWith('__DICTATE__')) {
1602
+ const maxSec = parseInt(result.injectPrompt.slice('__DICTATE__'.length), 10) || 30;
1603
+ const transcript = await dictateOnce(config, maxSec);
1604
+ if (!transcript) {
1605
+ console.log(chalk.dim(' [dictate] no transcript captured.'));
1606
+ continue;
1607
+ }
1608
+ console.log(theme.dim(' [dictate] ') + chalk.white(transcript));
1609
+ messages.push({ role: 'user', content: transcript });
1610
+ }
1611
+ else {
1612
+ messages.push({ role: 'user', content: result.injectPrompt });
1613
+ }
1209
1614
  await runQuery({ config, messages, cwd: process.cwd(), rl, sessionId: session.id, mode: mode.current });
1210
1615
  await autoSave(session, messages);
1211
1616
  continue;