droid-patch 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -690,6 +690,7 @@ const MIN_RENDER_INTERVAL_MS = IS_APPLE_TERMINAL ? 1000 : 500;
690
690
  const START_MS = Date.now();
691
691
  const ARGS = process.argv.slice(2);
692
692
  const PGID = Number(process.env.DROID_STATUSLINE_PGID || '');
693
+ const SESSION_ID_RE = /"sessionId":"([0-9a-f-]{36})"/i;
693
694
 
694
695
  function sleep(ms) {
695
696
  return new Promise((r) => setTimeout(r, ms));
@@ -699,6 +700,40 @@ function isPositiveInt(n) {
699
700
  return Number.isFinite(n) && n > 0;
700
701
  }
701
702
 
703
+ function extractSessionIdFromLine(line) {
704
+ if (!line) return null;
705
+ const m = String(line).match(SESSION_ID_RE);
706
+ return m ? m[1] : null;
707
+ }
708
+
709
+ function parseLineTimestampMs(line) {
710
+ const s = String(line || '');
711
+ if (!s || s[0] !== '[') return null;
712
+ const end = s.indexOf(']');
713
+ if (end <= 1) return null;
714
+ const raw = s.slice(1, end);
715
+ const ms = Date.parse(raw);
716
+ return Number.isFinite(ms) ? ms : null;
717
+ }
718
+
719
+ function safeStatMtimeMs(p) {
720
+ try {
721
+ const stat = fs.statSync(p);
722
+ const ms = Number(stat?.mtimeMs ?? 0);
723
+ return Number.isFinite(ms) ? ms : 0;
724
+ } catch {
725
+ return 0;
726
+ }
727
+ }
728
+
729
+ function nextCompactionState(line, current) {
730
+ if (!line) return current;
731
+ if (line.includes('[Compaction] Start')) return true;
732
+ const endMarkers = ['End', 'Done', 'Finish', 'Finished', 'Complete', 'Completed'];
733
+ if (endMarkers.some(m => line.includes('[Compaction] ' + m))) return false;
734
+ return current;
735
+ }
736
+
702
737
  function firstNonNull(promises) {
703
738
  const list = Array.isArray(promises) ? promises : [];
704
739
  if (list.length === 0) return Promise.resolve(null);
@@ -1161,7 +1196,7 @@ function buildLine(params) {
1161
1196
  }
1162
1197
 
1163
1198
  async function main() {
1164
- const factoryConfig = readJsonFile(CONFIG_PATH) || {};
1199
+ let factoryConfig = readJsonFile(CONFIG_PATH) || {};
1165
1200
 
1166
1201
  const cwd = process.cwd();
1167
1202
  const cwdBase = path.basename(cwd) || cwd;
@@ -1208,21 +1243,68 @@ async function main() {
1208
1243
  }
1209
1244
 
1210
1245
  if (!sessionId || !workspaceDir) return;
1246
+ let sessionIdLower = String(sessionId).toLowerCase();
1247
+
1248
+ let settingsPath = '';
1249
+ let sessionSettings = {};
1250
+ ({ settingsPath, settings: sessionSettings } = resolveSessionSettings(workspaceDir, sessionId));
1251
+
1252
+ let configMtimeMs = safeStatMtimeMs(CONFIG_PATH);
1253
+ let globalSettingsMtimeMs = safeStatMtimeMs(GLOBAL_SETTINGS_PATH);
1254
+ let globalSettingsModel = resolveGlobalSettingsModel();
1255
+
1256
+ let modelId =
1257
+ (sessionSettings && typeof sessionSettings.model === 'string' ? sessionSettings.model : null) ||
1258
+ globalSettingsModel ||
1259
+ null;
1260
+
1261
+ let provider =
1262
+ sessionSettings && typeof sessionSettings.providerLock === 'string'
1263
+ ? sessionSettings.providerLock
1264
+ : resolveProvider(modelId, factoryConfig);
1265
+ let underlyingModel = resolveUnderlyingModelId(modelId, factoryConfig) || modelId || 'unknown';
1266
+
1267
+ function refreshModel() {
1268
+ const nextModelId =
1269
+ (sessionSettings && typeof sessionSettings.model === 'string' ? sessionSettings.model : null) ||
1270
+ globalSettingsModel ||
1271
+ null;
1272
+
1273
+ // Use providerLock if set, otherwise resolve from model/config (same logic as initialization)
1274
+ const nextProvider =
1275
+ sessionSettings && typeof sessionSettings.providerLock === 'string'
1276
+ ? sessionSettings.providerLock
1277
+ : resolveProvider(nextModelId, factoryConfig);
1278
+ const nextUnderlying = resolveUnderlyingModelId(nextModelId, factoryConfig) || nextModelId || 'unknown';
1279
+
1280
+ let changed = false;
1281
+ if (nextModelId !== modelId) {
1282
+ modelId = nextModelId;
1283
+ changed = true;
1284
+ }
1285
+ if (nextProvider !== provider) {
1286
+ provider = nextProvider;
1287
+ changed = true;
1288
+ }
1289
+ if (nextUnderlying !== underlyingModel) {
1290
+ underlyingModel = nextUnderlying;
1291
+ changed = true;
1292
+ }
1211
1293
 
1212
- const { settingsPath, settings } = resolveSessionSettings(workspaceDir, sessionId);
1213
- const modelId =
1214
- (settings && typeof settings.model === 'string' ? settings.model : null) || resolveGlobalSettingsModel();
1215
-
1216
- const provider = resolveProvider(modelId, factoryConfig);
1217
- const underlyingModel = resolveUnderlyingModelId(modelId, factoryConfig) || modelId || 'unknown';
1294
+ if (changed) renderNow();
1295
+ }
1218
1296
 
1219
1297
  let last = { cacheReadInputTokens: 0, contextCount: 0, outputTokens: 0 };
1220
- let sessionUsage = settings && typeof settings.tokenUsage === 'object' && settings.tokenUsage ? settings.tokenUsage : {};
1298
+ let sessionUsage =
1299
+ sessionSettings && typeof sessionSettings.tokenUsage === 'object' && sessionSettings.tokenUsage
1300
+ ? sessionSettings.tokenUsage
1301
+ : {};
1221
1302
  let compacting = false;
1222
1303
  let lastRenderAt = 0;
1223
1304
  let lastRenderedLine = '';
1224
1305
  let gitBranch = '';
1225
1306
  let gitDiff = '';
1307
+ let lastContextMs = 0;
1226
1308
 
1227
1309
  function renderNow() {
1228
1310
  const usedTokens = (last.cacheReadInputTokens || 0) + (last.contextCount || 0);
@@ -1257,14 +1339,40 @@ async function main() {
1257
1339
  } catch {}
1258
1340
  }, 0).unref();
1259
1341
 
1260
- // Seed prompt-context usage from existing logs (important for resumed sessions).
1261
- // Do this asynchronously to avoid delaying the first statusline frame.
1262
- if (resumeFlag || resumeId) {
1342
+ let reseedInProgress = false;
1343
+ let reseedQueued = false;
1344
+
1345
+ function updateLastFromContext(ctx, updateOutputTokens, tsMs) {
1346
+ const ts = Number.isFinite(tsMs) ? tsMs : null;
1347
+ if (ts != null && lastContextMs && ts < lastContextMs) return false;
1348
+ const cacheRead = Number(ctx?.cacheReadInputTokens);
1349
+ const contextCount = Number(ctx?.contextCount);
1350
+ const out = Number(ctx?.outputTokens);
1351
+ if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
1352
+ if (Number.isFinite(contextCount)) last.contextCount = contextCount;
1353
+ if (updateOutputTokens && Number.isFinite(out)) last.outputTokens = out;
1354
+ if (ts != null) lastContextMs = ts;
1355
+ return true;
1356
+ }
1357
+
1358
+ function seedLastContextFromLog(options) {
1359
+ const opts = options || {};
1360
+ const maxScanBytes = Number.isFinite(opts.maxScanBytes) ? opts.maxScanBytes : 64 * 1024 * 1024;
1361
+ const preferStreaming = !!opts.preferStreaming;
1362
+ const minTimestampMs = Number.isFinite(lastContextMs) && lastContextMs > 0 ? lastContextMs : 0;
1363
+ const earlyStopAfterBestBytes = Math.min(2 * 1024 * 1024, Math.max(256 * 1024, maxScanBytes));
1364
+
1365
+ if (reseedInProgress) {
1366
+ reseedQueued = true;
1367
+ return;
1368
+ }
1369
+ reseedInProgress = true;
1370
+
1263
1371
  setTimeout(() => {
1264
1372
  try {
1265
- // Backward scan to find the most recent streaming-context entry for this session.
1266
- // The log can be large and shared across multiple sessions.
1267
- const MAX_SCAN_BYTES = 64 * 1024 * 1024; // 64 MiB
1373
+ // Backward scan to find the most recent context entry for this session.
1374
+ // Prefer streaming context if requested; otherwise accept any context line
1375
+ // that includes cacheReadInputTokens/contextCount fields.
1268
1376
  const CHUNK_BYTES = 1024 * 1024; // 1 MiB
1269
1377
 
1270
1378
  const fd = fs.openSync(LOG_PATH, 'r');
@@ -1274,15 +1382,20 @@ async function main() {
1274
1382
  let pos = size;
1275
1383
  let scanned = 0;
1276
1384
  let remainder = '';
1277
- let seeded = false;
1385
+ let bestCtx = null;
1386
+ let bestIsStreaming = false;
1387
+ let bestTs = null;
1388
+ let bestHasTs = false;
1389
+ let bytesSinceBest = 0;
1278
1390
 
1279
- while (pos > 0 && scanned < MAX_SCAN_BYTES && !seeded) {
1391
+ while (pos > 0 && scanned < maxScanBytes && (!bestHasTs || bytesSinceBest < earlyStopAfterBestBytes)) {
1280
1392
  const readSize = Math.min(CHUNK_BYTES, pos);
1281
1393
  const start = pos - readSize;
1282
1394
  const buf = Buffer.alloc(readSize);
1283
1395
  fs.readSync(fd, buf, 0, readSize, start);
1284
1396
  pos = start;
1285
1397
  scanned += readSize;
1398
+ bytesSinceBest += readSize;
1286
1399
 
1287
1400
  let text = buf.toString('utf8') + remainder;
1288
1401
  let lines = String(text).split('\\n');
@@ -1296,8 +1409,12 @@ async function main() {
1296
1409
  const line = String(lines[i] || '').trimEnd();
1297
1410
  if (!line) continue;
1298
1411
  if (!line.includes('Context:')) continue;
1299
- if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
1300
- if (!line.includes('[Agent] Streaming result')) continue;
1412
+ const sid = extractSessionIdFromLine(line);
1413
+ if (!sid || String(sid).toLowerCase() !== sessionIdLower) continue;
1414
+
1415
+ const isStreaming = line.includes('[Agent] Streaming result');
1416
+ if (preferStreaming && !isStreaming) continue;
1417
+
1301
1418
  const ctxIndex = line.indexOf('Context: ');
1302
1419
  if (ctxIndex === -1) continue;
1303
1420
  const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
@@ -1307,45 +1424,101 @@ async function main() {
1307
1424
  } catch {
1308
1425
  continue;
1309
1426
  }
1310
- const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
1311
- const contextCount = Number(ctx?.contextCount ?? 0);
1312
- const out = Number(ctx?.outputTokens ?? 0);
1313
- if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
1314
- if (Number.isFinite(contextCount)) last.contextCount = contextCount;
1315
- if (Number.isFinite(out)) last.outputTokens = out;
1316
- seeded = true;
1317
- break;
1427
+
1428
+ const cacheRead = Number(ctx?.cacheReadInputTokens);
1429
+ const contextCount = Number(ctx?.contextCount);
1430
+ const hasUsage = Number.isFinite(cacheRead) || Number.isFinite(contextCount);
1431
+ if (!hasUsage) continue;
1432
+
1433
+ const ts = parseLineTimestampMs(line);
1434
+ if (ts != null && minTimestampMs && ts < minTimestampMs) {
1435
+ continue;
1436
+ }
1437
+
1438
+ if (ts != null) {
1439
+ if (!bestHasTs || ts > bestTs) {
1440
+ bestCtx = ctx;
1441
+ bestIsStreaming = isStreaming;
1442
+ bestTs = ts;
1443
+ bestHasTs = true;
1444
+ bytesSinceBest = 0;
1445
+ }
1446
+ } else if (!bestHasTs && !bestCtx) {
1447
+ // No timestamps available yet: the first match when scanning backward
1448
+ // is the most recent in file order.
1449
+ bestCtx = ctx;
1450
+ bestIsStreaming = isStreaming;
1451
+ bestTs = null;
1452
+ }
1318
1453
  }
1319
1454
 
1320
1455
  if (remainder.length > 8192) remainder = remainder.slice(-8192);
1321
1456
  }
1457
+
1458
+ if (bestCtx) {
1459
+ updateLastFromContext(bestCtx, bestIsStreaming, bestTs);
1460
+ }
1322
1461
  } finally {
1323
1462
  try {
1324
1463
  fs.closeSync(fd);
1325
1464
  } catch {}
1326
1465
  }
1327
-
1328
- renderNow();
1329
1466
  } catch {
1330
1467
  // ignore
1468
+ } finally {
1469
+ reseedInProgress = false;
1470
+ if (reseedQueued) {
1471
+ reseedQueued = false;
1472
+ seedLastContextFromLog({ maxScanBytes, preferStreaming });
1473
+ return;
1474
+ }
1475
+ renderNow();
1331
1476
  }
1332
1477
  }, 0).unref();
1333
1478
  }
1334
1479
 
1480
+ // Seed prompt-context usage from existing logs (important for resumed sessions).
1481
+ // Do this asynchronously to avoid delaying the first statusline frame.
1482
+ let initialSeedDone = false;
1483
+ if (resumeFlag || resumeId) {
1484
+ initialSeedDone = true;
1485
+ seedLastContextFromLog({ maxScanBytes: 64 * 1024 * 1024, preferStreaming: true });
1486
+ }
1487
+
1335
1488
  // Watch session settings for autonomy/reasoning changes (cheap polling with mtime).
1336
1489
  let settingsMtimeMs = 0;
1490
+ let lastCtxPollMs = 0;
1337
1491
  setInterval(() => {
1492
+ // Refresh config/global settings if they changed (model display depends on these).
1493
+ const configMtime = safeStatMtimeMs(CONFIG_PATH);
1494
+ if (configMtime && configMtime !== configMtimeMs) {
1495
+ configMtimeMs = configMtime;
1496
+ factoryConfig = readJsonFile(CONFIG_PATH) || {};
1497
+ refreshModel();
1498
+ }
1499
+
1500
+ const globalMtime = safeStatMtimeMs(GLOBAL_SETTINGS_PATH);
1501
+ if (globalMtime && globalMtime !== globalSettingsMtimeMs) {
1502
+ globalSettingsMtimeMs = globalMtime;
1503
+ globalSettingsModel = resolveGlobalSettingsModel();
1504
+ refreshModel();
1505
+ }
1506
+
1338
1507
  try {
1339
1508
  const stat = fs.statSync(settingsPath);
1340
1509
  if (stat.mtimeMs === settingsMtimeMs) return;
1341
1510
  settingsMtimeMs = stat.mtimeMs;
1342
1511
  const next = readJsonFile(settingsPath) || {};
1512
+ sessionSettings = next;
1343
1513
 
1344
1514
  // Keep session token usage in sync (used by /status).
1345
1515
  if (next && typeof next.tokenUsage === 'object' && next.tokenUsage) {
1346
1516
  sessionUsage = next.tokenUsage;
1347
1517
  }
1348
1518
 
1519
+ // Keep model/provider in sync (model can change during a running session).
1520
+ refreshModel();
1521
+
1349
1522
  const now = Date.now();
1350
1523
  if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1351
1524
  lastRenderAt = now;
@@ -1356,6 +1529,48 @@ async function main() {
1356
1529
  }
1357
1530
  }, 750).unref();
1358
1531
 
1532
+ // Fallback: periodically rescan log if context is still zero after startup.
1533
+ // This handles cases where tail misses early log entries.
1534
+ setInterval(() => {
1535
+ const now = Date.now();
1536
+ if (now - START_MS < 3000) return; // wait 3s after startup
1537
+ if (last.contextCount > 0 || last.cacheReadInputTokens > 0) return; // already have data
1538
+ if (now - lastCtxPollMs < 5000) return; // throttle to every 5s
1539
+ lastCtxPollMs = now;
1540
+ seedLastContextFromLog({ maxScanBytes: 4 * 1024 * 1024, preferStreaming: false });
1541
+ }, 2000).unref();
1542
+
1543
+ function switchToSession(nextSessionId) {
1544
+ if (!nextSessionId || !isUuid(nextSessionId)) return;
1545
+ const nextLower = String(nextSessionId).toLowerCase();
1546
+ if (nextLower === sessionIdLower) return;
1547
+
1548
+ sessionId = nextSessionId;
1549
+ sessionIdLower = nextLower;
1550
+
1551
+ const resolved = resolveSessionSettings(workspaceDir, nextSessionId);
1552
+ settingsPath = resolved.settingsPath;
1553
+ sessionSettings = resolved.settings || {};
1554
+
1555
+ sessionUsage =
1556
+ sessionSettings && typeof sessionSettings.tokenUsage === 'object' && sessionSettings.tokenUsage
1557
+ ? sessionSettings.tokenUsage
1558
+ : {};
1559
+
1560
+ // Reset cached state for the new session.
1561
+ last = { cacheReadInputTokens: 0, contextCount: 0, outputTokens: 0 };
1562
+ lastContextMs = 0;
1563
+ compacting = false;
1564
+ settingsMtimeMs = 0;
1565
+ lastCtxPollMs = 0;
1566
+
1567
+ refreshModel();
1568
+ renderNow();
1569
+
1570
+ // Best-effort: if the new session already has Context lines in the log, seed quickly.
1571
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1572
+ }
1573
+
1359
1574
  // Follow the Factory log and update based on session-scoped events.
1360
1575
  const tail = spawn('tail', ['-n', '0', '-F', LOG_PATH], {
1361
1576
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -1370,8 +1585,90 @@ async function main() {
1370
1585
  const line = buffer.slice(0, idx).trimEnd();
1371
1586
  buffer = buffer.slice(idx + 1);
1372
1587
 
1373
- if (!line.includes('Context:')) continue;
1374
- if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
1588
+ const tsMs = parseLineTimestampMs(line);
1589
+ const lineSessionId = extractSessionIdFromLine(line);
1590
+ const isSessionLine =
1591
+ lineSessionId && String(lineSessionId).toLowerCase() === sessionIdLower;
1592
+
1593
+ // /compress (aka /compact) can create a new session ID. Follow it so ctx/model keep updating.
1594
+ if (line.includes('oldSessionId') && line.includes('newSessionId') && line.includes('Context:')) {
1595
+ const ctxIndex = line.indexOf('Context: ');
1596
+ if (ctxIndex !== -1) {
1597
+ const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
1598
+ try {
1599
+ const meta = JSON.parse(jsonStr);
1600
+ const oldId = meta?.oldSessionId;
1601
+ const newId = meta?.newSessionId;
1602
+ if (
1603
+ isUuid(oldId) &&
1604
+ isUuid(newId) &&
1605
+ String(oldId).toLowerCase() === sessionIdLower &&
1606
+ String(newId).toLowerCase() !== sessionIdLower
1607
+ ) {
1608
+ switchToSession(String(newId));
1609
+ continue;
1610
+ }
1611
+ } catch {
1612
+ // ignore
1613
+ }
1614
+ }
1615
+ }
1616
+
1617
+ let compactionChanged = false;
1618
+ let compactionEnded = false;
1619
+ if (line.includes('[Compaction]')) {
1620
+ // Accept session-scoped compaction lines; allow end markers to clear even
1621
+ // if the line lacks a session id (some builds omit Context on end lines).
1622
+ if (isSessionLine || (compacting && !lineSessionId)) {
1623
+ const next = nextCompactionState(line, compacting);
1624
+ if (next !== compacting) {
1625
+ compacting = next;
1626
+ compactionChanged = true;
1627
+ if (!compacting) compactionEnded = true;
1628
+ }
1629
+ }
1630
+ }
1631
+
1632
+ if (compactionChanged && compacting) {
1633
+ // Compaction can start after a context-limit error. Ensure we display the latest
1634
+ // pre-compaction ctx by reseeding from log (tail can miss bursts).
1635
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: true });
1636
+ }
1637
+
1638
+ if (compactionEnded) {
1639
+ // ctx usage changes dramatically after compaction, but the next Context line
1640
+ // can be delayed. Clear displayed ctx immediately to avoid showing stale numbers.
1641
+ last.cacheReadInputTokens = 0;
1642
+ last.contextCount = 0;
1643
+ if (tsMs != null) lastContextMs = tsMs;
1644
+ }
1645
+
1646
+ if (!line.includes('Context:')) {
1647
+ if (compactionChanged) {
1648
+ lastRenderAt = Date.now();
1649
+ renderNow();
1650
+ }
1651
+ if (compactionEnded) {
1652
+ // Compaction often completes between turns. Refresh ctx numbers promptly
1653
+ // by rescanning the most recent Context entry for this session.
1654
+ setTimeout(() => {
1655
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1656
+ }, 250).unref();
1657
+ }
1658
+ continue;
1659
+ }
1660
+ if (!isSessionLine) {
1661
+ if (compactionChanged) {
1662
+ lastRenderAt = Date.now();
1663
+ renderNow();
1664
+ }
1665
+ if (compactionEnded) {
1666
+ setTimeout(() => {
1667
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1668
+ }, 250).unref();
1669
+ }
1670
+ continue;
1671
+ }
1375
1672
 
1376
1673
  const ctxIndex = line.indexOf('Context: ');
1377
1674
  if (ctxIndex === -1) continue;
@@ -1380,28 +1677,41 @@ async function main() {
1380
1677
  try {
1381
1678
  ctx = JSON.parse(jsonStr);
1382
1679
  } catch {
1680
+ if (compactionChanged) {
1681
+ lastRenderAt = Date.now();
1682
+ renderNow();
1683
+ }
1383
1684
  continue;
1384
1685
  }
1385
1686
 
1386
- // Streaming token usage (best source for current context usage).
1387
- if (line.includes('[Agent] Streaming result')) {
1388
- const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
1389
- const contextCount = Number(ctx?.contextCount ?? 0);
1390
- const out = Number(ctx?.outputTokens ?? 0);
1391
- if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
1392
- if (Number.isFinite(contextCount)) last.contextCount = contextCount;
1393
- if (Number.isFinite(out)) last.outputTokens = out;
1687
+ // Context usage can appear on multiple session-scoped log lines; update whenever present.
1688
+ // (Streaming is still the best source for outputTokens / LastOut.)
1689
+ updateLastFromContext(ctx, false, tsMs);
1690
+
1691
+ // For new sessions: if this is the first valid Context line and ctx is still 0,
1692
+ // trigger a reseed to catch any earlier log entries we might have missed.
1693
+ if (!initialSeedDone && last.contextCount === 0) {
1694
+ initialSeedDone = true;
1695
+ setTimeout(() => {
1696
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1697
+ }, 100).unref();
1394
1698
  }
1395
1699
 
1396
- // Compaction state hint.
1397
- if (line.includes('[Compaction] Start')) compacting = true;
1398
- if (line.includes('[Compaction] End')) compacting = false;
1700
+ if (line.includes('[Agent] Streaming result')) {
1701
+ updateLastFromContext(ctx, true, tsMs);
1702
+ }
1399
1703
 
1400
1704
  const now = Date.now();
1401
- if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1705
+ if (compactionChanged || now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1402
1706
  lastRenderAt = now;
1403
1707
  renderNow();
1404
1708
  }
1709
+
1710
+ if (compactionEnded) {
1711
+ setTimeout(() => {
1712
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1713
+ }, 250).unref();
1714
+ }
1405
1715
  }
1406
1716
  });
1407
1717
 
@@ -1419,986 +1729,944 @@ main().catch(() => {});
1419
1729
  `;
1420
1730
  }
1421
1731
  function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath) {
1422
- return `#!/usr/bin/env python3
1423
- # Droid with Statusline (PTY proxy)
1424
- # Auto-generated by droid-patch --statusline
1425
- #
1426
- # Design goal (KISS + no flicker):
1427
- # - droid draws into a child PTY sized to (terminal_rows - RESERVED_ROWS)
1428
- # - this wrapper is the ONLY writer to the real terminal
1429
- # - a Node monitor emits statusline frames to a pipe; wrapper renders the latest frame
1430
- # onto the reserved bottom row (a stable "second footer line").
1431
-
1432
- import os
1433
- import pty
1434
- import re
1435
- import select
1436
- import signal
1437
- import struct
1438
- import subprocess
1439
- import sys
1440
- import termios
1441
- import time
1442
- import tty
1443
- import fcntl
1444
-
1445
- EXEC_TARGET = ${JSON.stringify(execTargetPath)}
1446
- STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)}
1447
- SESSIONS_SCRIPT = ${sessionsScriptPath ? JSON.stringify(sessionsScriptPath) : "None"}
1448
-
1449
- IS_APPLE_TERMINAL = os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
1450
- MIN_RENDER_INTERVAL_MS = 800 if IS_APPLE_TERMINAL else 400
1451
- QUIET_MS = 50 # Reduced to prevent statusline disappearing
1452
- FORCE_REPAINT_INTERVAL_MS = 2000 # Force repaint every 2 seconds
1453
- RESERVED_ROWS = 1
1454
-
1455
- BYPASS_FLAGS = {"--help", "-h", "--version", "-V"}
1456
- BYPASS_COMMANDS = {"help", "version", "completion", "completions", "exec"}
1457
-
1458
- def _should_passthrough(argv):
1459
- # Any help/version flags before "--"
1460
- for a in argv:
1461
- if a == "--":
1462
- break
1463
- if a in BYPASS_FLAGS:
1464
- return True
1465
-
1466
- # Top-level command token
1467
- end_opts = False
1468
- cmd = None
1469
- for a in argv:
1470
- if a == "--":
1471
- end_opts = True
1472
- continue
1473
- if (not end_opts) and a.startswith("-"):
1474
- continue
1475
- cmd = a
1476
- break
1477
-
1478
- return cmd in BYPASS_COMMANDS
1479
-
1480
- def _exec_passthrough():
1481
- try:
1482
- os.execvp(EXEC_TARGET, [EXEC_TARGET] + sys.argv[1:])
1483
- except Exception as e:
1484
- sys.stderr.write(f"[statusline] passthrough failed: {e}\\n")
1485
- sys.exit(1)
1486
-
1487
- def _is_sessions_command(argv):
1488
- for a in argv:
1489
- if a == "--":
1490
- return False
1491
- if a == "--sessions":
1492
- return True
1493
- return False
1494
-
1495
- def _run_sessions():
1496
- if SESSIONS_SCRIPT and os.path.exists(SESSIONS_SCRIPT):
1497
- os.execvp("node", ["node", SESSIONS_SCRIPT])
1498
- else:
1499
- sys.stderr.write("[statusline] sessions script not found\\n")
1500
- sys.exit(1)
1501
-
1502
- # Handle --sessions command
1503
- if _is_sessions_command(sys.argv[1:]):
1504
- _run_sessions()
1505
-
1506
- # Passthrough for non-interactive/meta commands (avoid clearing screen / PTY proxy)
1507
- if (not sys.stdin.isatty()) or (not sys.stdout.isatty()) or _should_passthrough(sys.argv[1:]):
1508
- _exec_passthrough()
1509
-
1510
- ANSI_RE = re.compile(r"\\x1b\\[[0-9;]*m")
1511
- RESET_SGR = "\\x1b[0m"
1512
-
1513
- def _term_size():
1514
- try:
1515
- sz = os.get_terminal_size(sys.stdout.fileno())
1516
- return int(sz.lines), int(sz.columns)
1517
- except Exception:
1518
- return 24, 80
1519
-
1520
- def _set_winsize(fd: int, rows: int, cols: int) -> None:
1521
- try:
1522
- winsz = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
1523
- fcntl.ioctl(fd, termios.TIOCSWINSZ, winsz)
1524
- except Exception:
1525
- pass
1526
-
1527
- def _visible_width(text: str) -> int:
1528
- # Remove only SGR sequences; good enough for our generated segments.
1529
- stripped = ANSI_RE.sub("", text)
1530
- return len(stripped)
1531
-
1532
- def _clamp_ansi(text: str, cols: int) -> str:
1533
- if cols <= 0:
1534
- return text
1535
- # Avoid writing into the last column. Some terminals keep an implicit wrap-pending state
1536
- # when the last column is filled, and the next printable character can trigger a scroll.
1537
- cols = cols - 1 if cols > 1 else cols
1538
- if cols < 10:
1539
- return text
1540
- visible = 0
1541
- i = 0
1542
- out = []
1543
- while i < len(text):
1544
- ch = text[i]
1545
- if ch == "\\x1b":
1546
- m = text.find("m", i)
1547
- if m != -1:
1548
- out.append(text[i : m + 1])
1549
- i = m + 1
1550
- continue
1551
- out.append(ch)
1552
- i += 1
1553
- continue
1554
- if visible >= cols:
1555
- break
1556
- out.append(ch)
1557
- i += 1
1558
- visible += 1
1559
- if i < len(text) and cols >= 1:
1560
- if visible >= cols:
1561
- if out:
1562
- out[-1] = "…"
1563
- else:
1564
- out.append("…")
1565
- else:
1566
- out.append("…")
1567
- out.append("\\x1b[0m")
1568
- return "".join(out)
1569
-
1570
- def _split_segments(text: str):
1571
- if not text:
1572
- return []
1573
- segments = []
1574
- start = 0
1575
- while True:
1576
- idx = text.find(RESET_SGR, start)
1577
- if idx == -1:
1578
- tail = text[start:]
1579
- if tail:
1580
- segments.append(tail)
1581
- break
1582
- seg = text[start : idx + len(RESET_SGR)]
1583
- if seg:
1584
- segments.append(seg)
1585
- start = idx + len(RESET_SGR)
1586
- return segments
1587
-
1588
- def _wrap_segments(segments, cols: int):
1589
- if not segments:
1590
- return [""]
1591
- if cols <= 0:
1592
- return ["".join(segments)]
1593
-
1594
- lines = []
1595
- cur = []
1596
- cur_w = 0
1597
-
1598
- for seg in segments:
1599
- seg_w = _visible_width(seg)
1600
- if seg_w <= 0:
1601
- continue
1602
-
1603
- if not cur:
1604
- if seg_w > cols:
1605
- seg = _clamp_ansi(seg, cols)
1606
- seg_w = _visible_width(seg)
1607
- cur = [seg]
1608
- cur_w = seg_w
1609
- continue
1610
-
1611
- if cur_w + seg_w <= cols:
1612
- cur.append(seg)
1613
- cur_w += seg_w
1614
- else:
1615
- lines.append("".join(cur))
1616
- if seg_w > cols:
1617
- seg = _clamp_ansi(seg, cols)
1618
- seg_w = _visible_width(seg)
1619
- cur = [seg]
1620
- cur_w = seg_w
1621
-
1622
- if cur:
1623
- lines.append("".join(cur))
1624
-
1625
- return lines if lines else [""]
1626
-
1627
- class StatusRenderer:
1628
- def __init__(self):
1629
- self._raw = ""
1630
- self._segments = []
1631
- self._lines = [""]
1632
- self._active_reserved_rows = RESERVED_ROWS
1633
- self._last_render_ms = 0
1634
- self._last_child_out_ms = 0
1635
- self._force_repaint = False
1636
- self._urgent = False
1637
- self._cursor_visible = True
1638
-
1639
- def note_child_output(self):
1640
- self._last_child_out_ms = int(time.time() * 1000)
1641
-
1642
- def set_cursor_visible(self, visible: bool):
1643
- self._cursor_visible = bool(visible)
1644
-
1645
- def force_repaint(self, urgent: bool = False):
1646
- self._force_repaint = True
1647
- if urgent:
1648
- self._urgent = True
1649
-
1650
- def set_active_reserved_rows(self, reserved_rows: int):
1651
- try:
1652
- self._active_reserved_rows = max(1, int(reserved_rows or 1))
1653
- except Exception:
1654
- self._active_reserved_rows = 1
1655
-
1656
- def set_line(self, line: str):
1657
- if line != self._raw:
1658
- self._raw = line
1659
- self._segments = _split_segments(line)
1660
- self._force_repaint = True
1661
-
1662
- def _write(self, b: bytes) -> None:
1663
- try:
1664
- os.write(sys.stdout.fileno(), b)
1665
- except Exception:
1666
- pass
1667
-
1668
- def desired_reserved_rows(self, physical_rows: int, cols: int, min_reserved: int):
1669
- try:
1670
- rows = int(physical_rows or 24)
1671
- except Exception:
1672
- rows = 24
1673
- try:
1674
- cols = int(cols or 80)
1675
- except Exception:
1676
- cols = 80
1677
-
1678
- max_reserved = max(1, rows - 4)
1679
- segments = self._segments if self._segments else ([self._raw] if self._raw else [])
1680
- lines = _wrap_segments(segments, cols) if segments else [""]
1681
-
1682
- needed = min(len(lines), max_reserved)
1683
- desired = max(int(min_reserved or 1), needed)
1684
- desired = min(desired, max_reserved)
1685
-
1686
- if len(lines) < desired:
1687
- lines = [""] * (desired - len(lines)) + lines
1688
- if len(lines) > desired:
1689
- lines = lines[-desired:]
1690
-
1691
- self._lines = lines
1692
- return desired
1693
-
1694
- def clear_reserved_area(
1695
- self,
1696
- physical_rows: int,
1697
- cols: int,
1698
- reserved_rows: int,
1699
- restore_row: int = 1,
1700
- restore_col: int = 1,
1701
- ):
1702
- try:
1703
- rows = int(physical_rows or 24)
1704
- except Exception:
1705
- rows = 24
1706
- try:
1707
- cols = int(cols or 80)
1708
- except Exception:
1709
- cols = 80
1710
- try:
1711
- reserved = max(1, int(reserved_rows or 1))
1712
- except Exception:
1713
- reserved = 1
1714
-
1715
- reserved = min(reserved, rows)
1716
- start_row = rows - reserved + 1
1717
- parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR]
1718
- for i in range(reserved):
1719
- parts.append(f"\\x1b[{start_row + i};1H\\x1b[2K")
1720
- parts.append(f"\\x1b[{restore_row};{restore_col}H")
1721
- parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
1722
- parts.append("\\x1b[?2026l")
1723
- self._write("".join(parts).encode("utf-8", "ignore"))
1724
-
1725
- def render(self, physical_rows: int, cols: int, restore_row: int = 1, restore_col: int = 1) -> None:
1726
- now_ms = int(time.time() * 1000)
1727
- if not self._force_repaint:
1728
- return
1729
- if not self._raw:
1730
- self._force_repaint = False
1731
- self._urgent = False
1732
- return
1733
- if (not self._urgent) and (now_ms - self._last_render_ms < MIN_RENDER_INTERVAL_MS):
1734
- return
1735
- # Avoid repainting while child is actively writing (reduces flicker on macOS Terminal).
1736
- if (not self._urgent) and (QUIET_MS > 0 and now_ms - self._last_child_out_ms < QUIET_MS):
1737
- return
1738
-
1739
- try:
1740
- rows = int(physical_rows or 24)
1741
- except Exception:
1742
- rows = 24
1743
- try:
1744
- cols = int(cols or 80)
1745
- except Exception:
1746
- cols = 80
1747
-
1748
- if cols <= 0:
1749
- cols = 80
1750
-
1751
- reserved = max(1, min(self._active_reserved_rows, max(1, rows - 4)))
1752
- start_row = rows - reserved + 1
1753
-
1754
- lines = self._lines or [""]
1755
- if len(lines) < reserved:
1756
- lines = [""] * (reserved - len(lines)) + lines
1757
- if len(lines) > reserved:
1758
- lines = lines[-reserved:]
1759
-
1760
- child_rows = rows - reserved
1761
-
1762
- parts = ["\\x1b[?2026h", "\\x1b[?25l"]
1763
- # Always set scroll region to exclude statusline area
1764
- parts.append(f"\\x1b[1;{child_rows}r")
1765
- for i in range(reserved):
1766
- row = start_row + i
1767
- text = _clamp_ansi(lines[i], cols)
1768
- parts.append(f"\\x1b[{row};1H{RESET_SGR}\\x1b[2K")
1769
- parts.append(f"\\x1b[{row};1H{text}{RESET_SGR}")
1770
- parts.append(f"\\x1b[{restore_row};{restore_col}H")
1771
- parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
1772
- parts.append("\\x1b[?2026l")
1773
-
1774
- self._write("".join(parts).encode("utf-8", "ignore"))
1775
- self._last_render_ms = now_ms
1776
- self._force_repaint = False
1777
- self._urgent = False
1778
-
1779
- def clear(self):
1780
- r, c = _term_size()
1781
- self.clear_reserved_area(r, c, max(self._active_reserved_rows, RESERVED_ROWS))
1782
-
1783
-
1784
- class OutputRewriter:
1785
- # Rewrite a small subset of ANSI cursor positioning commands to ensure the child UI never
1786
- # draws into the reserved statusline rows.
1787
- #
1788
- # Key idea: many TUIs use "ESC[999;1H" to jump to the terminal bottom. If we forward that
1789
- # unmodified, it targets the *physical* bottom row, overwriting our statusline. We clamp it
1790
- # to "max_row" (physical_rows - reserved_rows) so the child's "bottom" becomes the line
1791
- # just above the statusline.
1792
- def __init__(self):
1793
- self._buf = b""
1794
-
1795
- def feed(self, chunk: bytes, max_row: int) -> bytes:
1796
- if not chunk:
1797
- return b""
1798
-
1799
- data = self._buf + chunk
1800
- self._buf = b""
1801
- out = bytearray()
1802
- n = len(data)
1803
- i = 0
1804
-
1805
- def _is_final_byte(v: int) -> bool:
1806
- return 0x40 <= v <= 0x7E
1807
-
1808
- while i < n:
1809
- b = data[i]
1810
- if b != 0x1B: # ESC
1811
- out.append(b)
1812
- i += 1
1813
- continue
1814
-
1815
- if i + 1 >= n:
1816
- self._buf = data[i:]
1817
- break
1818
-
1819
- nxt = data[i + 1]
1820
- if nxt != 0x5B: # not CSI
1821
- out.append(b)
1822
- i += 1
1823
- continue
1824
-
1825
- # CSI sequence: ESC [ ... <final>
1826
- j = i + 2
1827
- while j < n and not _is_final_byte(data[j]):
1828
- j += 1
1829
- if j >= n:
1830
- self._buf = data[i:]
1831
- break
1832
-
1833
- final = data[j]
1834
- seq = data[i : j + 1]
1835
-
1836
- if final in (ord("H"), ord("f")) and max_row > 0:
1837
- params = data[i + 2 : j]
1838
- try:
1839
- s = params.decode("ascii", "ignore")
1840
- except Exception:
1841
- s = ""
1842
-
1843
- # Only handle the simple numeric form (no private/DEC prefixes like "?")
1844
- if not s or s[0] in "0123456789;":
1845
- parts = s.split(";") if s else []
1846
- try:
1847
- row = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
1848
- except Exception:
1849
- row = 1
1850
- try:
1851
- col = int(parts[1]) if (len(parts) >= 2 and parts[1]) else 1
1852
- except Exception:
1853
- col = 1
1854
-
1855
- if row == 999 or row > max_row:
1856
- row = max_row
1857
- if row < 1:
1858
- row = 1
1859
- if col < 1:
1860
- col = 1
1861
-
1862
- new_params = f"{row};{col}".encode("ascii", "ignore")
1863
- seq = b"\\x1b[" + new_params + bytes([final])
1864
-
1865
- elif final == ord("r") and max_row > 0:
1866
- # DECSTBM - Set scrolling region. If the child resets to the full physical screen
1867
- # (e.g. ESC[r), the reserved statusline row becomes scrollable and our statusline
1868
- # will "float" upward when the UI scrolls. Clamp bottom to max_row (child area).
1869
- params = data[i + 2 : j]
1870
- try:
1871
- s = params.decode("ascii", "ignore")
1872
- except Exception:
1873
- s = ""
1874
-
1875
- # Only handle the simple numeric form (no private/DEC prefixes like "?")
1876
- if not s or s[0] in "0123456789;":
1877
- parts = s.split(";") if s else []
1878
- try:
1879
- top = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
1880
- except Exception:
1881
- top = 1
1882
- try:
1883
- bottom = int(parts[1]) if (len(parts) >= 2 and parts[1]) else max_row
1884
- except Exception:
1885
- bottom = max_row
1886
-
1887
- if top <= 0:
1888
- top = 1
1889
- if bottom <= 0 or bottom == 999 or bottom > max_row:
1890
- bottom = max_row
1891
- if top > bottom:
1892
- top = 1
1893
-
1894
- seq = f"\\x1b[{top};{bottom}r".encode("ascii", "ignore")
1895
-
1896
- out.extend(seq)
1897
- i = j + 1
1898
-
1899
- return bytes(out)
1900
-
1901
-
1902
- class CursorTracker:
1903
- # Best-effort cursor tracking so the wrapper can restore the cursor position without using
1904
- # ESC7/ESC8 (which droid/Ink also uses internally).
1905
- def __init__(self):
1906
- self.row = 1
1907
- self.col = 1
1908
- self._saved_row = 1
1909
- self._saved_col = 1
1910
- self._buf = b""
1911
- self._in_osc = False
1912
- self._utf8_cont = 0
1913
- self._wrap_pending = False
1914
-
1915
- def position(self):
1916
- return self.row, self.col
1917
-
1918
- def feed(self, chunk: bytes, max_row: int, max_col: int) -> None:
1919
- if not chunk:
1920
- return
1921
- try:
1922
- max_row = max(1, int(max_row or 1))
1923
- except Exception:
1924
- max_row = 1
1925
- try:
1926
- max_col = max(1, int(max_col or 1))
1927
- except Exception:
1928
- max_col = 1
1929
-
1930
- data = self._buf + chunk
1931
- self._buf = b""
1932
- n = len(data)
1933
- i = 0
1934
-
1935
- def _clamp():
1936
- if self.row < 1:
1937
- self.row = 1
1938
- elif self.row > max_row:
1939
- self.row = max_row
1940
- if self.col < 1:
1941
- self.col = 1
1942
- elif self.col > max_col:
1943
- self.col = max_col
1944
-
1945
- def _parse_int(v: str, default: int) -> int:
1946
- try:
1947
- return int(v) if v else default
1948
- except Exception:
1949
- return default
1950
-
1951
- while i < n:
1952
- b = data[i]
1953
-
1954
- if self._in_osc:
1955
- # OSC/DCS/etc are terminated by BEL or ST (ESC \\).
1956
- if b == 0x07:
1957
- self._in_osc = False
1958
- i += 1
1959
- continue
1960
- if b == 0x1B:
1961
- if i + 1 >= n:
1962
- self._buf = data[i:]
1963
- break
1964
- if data[i + 1] == 0x5C:
1965
- self._in_osc = False
1966
- i += 2
1967
- continue
1968
- i += 1
1969
- continue
1970
-
1971
- if self._utf8_cont > 0:
1972
- if 0x80 <= b <= 0xBF:
1973
- self._utf8_cont -= 1
1974
- i += 1
1975
- continue
1976
- self._utf8_cont = 0
1977
-
1978
- if b == 0x1B: # ESC
1979
- self._wrap_pending = False
1980
- if i + 1 >= n:
1981
- self._buf = data[i:]
1982
- break
1983
- nxt = data[i + 1]
1984
-
1985
- if nxt == 0x5B: # CSI
1986
- j = i + 2
1987
- while j < n and not (0x40 <= data[j] <= 0x7E):
1988
- j += 1
1989
- if j >= n:
1990
- self._buf = data[i:]
1991
- break
1992
- final = data[j]
1993
- params = data[i + 2 : j]
1994
- try:
1995
- s = params.decode("ascii", "ignore")
1996
- except Exception:
1997
- s = ""
1998
-
1999
- if s and s[0] not in "0123456789;":
2000
- i = j + 1
2001
- continue
2002
-
2003
- parts = s.split(";") if s else []
2004
- p0 = _parse_int(parts[0] if len(parts) >= 1 else "", 1)
2005
- p1 = _parse_int(parts[1] if len(parts) >= 2 else "", 1)
2006
-
2007
- if final in (ord("H"), ord("f")):
2008
- self.row = p0
2009
- self.col = p1
2010
- _clamp()
2011
- elif final == ord("A"):
2012
- self.row = max(1, self.row - p0)
2013
- elif final == ord("B"):
2014
- self.row = min(max_row, self.row + p0)
2015
- elif final == ord("C"):
2016
- self.col = min(max_col, self.col + p0)
2017
- elif final == ord("D"):
2018
- self.col = max(1, self.col - p0)
2019
- elif final == ord("E"):
2020
- self.row = min(max_row, self.row + p0)
2021
- self.col = 1
2022
- elif final == ord("F"):
2023
- self.row = max(1, self.row - p0)
2024
- self.col = 1
2025
- elif final == ord("G"):
2026
- self.col = p0
2027
- _clamp()
2028
- elif final == ord("d"):
2029
- self.row = p0
2030
- _clamp()
2031
- elif final == ord("r"):
2032
- # DECSTBM moves the cursor to the home position.
2033
- self.row = 1
2034
- self.col = 1
2035
- elif final == ord("s"):
2036
- self._saved_row = self.row
2037
- self._saved_col = self.col
2038
- elif final == ord("u"):
2039
- self.row = self._saved_row
2040
- self.col = self._saved_col
2041
- _clamp()
2042
-
2043
- i = j + 1
2044
- continue
2045
-
2046
- # OSC, DCS, PM, APC, SOS (terminated by ST or BEL).
2047
- if nxt == 0x5D or nxt in (0x50, 0x5E, 0x5F, 0x58):
2048
- self._in_osc = True
2049
- i += 2
2050
- continue
2051
-
2052
- # DECSC / DECRC
2053
- if nxt == 0x37:
2054
- self._saved_row = self.row
2055
- self._saved_col = self.col
2056
- i += 2
2057
- continue
2058
- if nxt == 0x38:
2059
- self.row = self._saved_row
2060
- self.col = self._saved_col
2061
- _clamp()
2062
- i += 2
2063
- continue
2064
-
2065
- # Other single-escape sequences (ignore).
2066
- i += 2
2067
- continue
2068
-
2069
- if b == 0x0D: # CR
2070
- self.col = 1
2071
- self._wrap_pending = False
2072
- i += 1
2073
- continue
2074
- if b in (0x0A, 0x0B, 0x0C): # LF/VT/FF
2075
- self.row = min(max_row, self.row + 1)
2076
- self._wrap_pending = False
2077
- i += 1
2078
- continue
2079
- if b == 0x08: # BS
2080
- self.col = max(1, self.col - 1)
2081
- self._wrap_pending = False
2082
- i += 1
2083
- continue
2084
- if b == 0x09: # TAB
2085
- next_stop = ((self.col - 1) // 8 + 1) * 8 + 1
2086
- self.col = min(max_col, next_stop)
2087
- self._wrap_pending = False
2088
- i += 1
2089
- continue
2090
-
2091
- if b < 0x20 or b == 0x7F:
2092
- i += 1
2093
- continue
2094
-
2095
- # Printable characters.
2096
- if self._wrap_pending:
2097
- self.row = min(max_row, self.row + 1)
2098
- self.col = 1
2099
- self._wrap_pending = False
2100
-
2101
- if b >= 0x80:
2102
- if (b & 0xE0) == 0xC0:
2103
- self._utf8_cont = 1
2104
- elif (b & 0xF0) == 0xE0:
2105
- self._utf8_cont = 2
2106
- elif (b & 0xF8) == 0xF0:
2107
- self._utf8_cont = 3
2108
- else:
2109
- self._utf8_cont = 0
2110
-
2111
- if self.col < max_col:
2112
- self.col += 1
2113
- else:
2114
- self.col = max_col
2115
- self._wrap_pending = True
2116
-
2117
- i += 1
2118
-
2119
-
2120
- def main():
2121
- # Start from a clean viewport. Droid's TUI assumes a fresh screen; without this,
2122
- # it can visually mix with prior shell output (especially when scrollback exists).
2123
- try:
2124
- os.write(sys.stdout.fileno(), b"\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l")
2125
- except Exception:
2126
- pass
2127
-
2128
- renderer = StatusRenderer()
2129
- renderer.set_line("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m")
2130
- renderer.force_repaint(True)
2131
-
2132
- physical_rows, physical_cols = _term_size()
2133
- effective_reserved_rows = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
2134
- renderer.set_active_reserved_rows(effective_reserved_rows)
2135
-
2136
- child_rows = max(4, physical_rows - effective_reserved_rows)
2137
- child_cols = max(10, physical_cols)
2138
-
2139
- # Reserve the bottom rows up-front, before the child starts writing.
2140
- try:
2141
- seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l"
2142
- os.write(sys.stdout.fileno(), seq.encode("utf-8", "ignore"))
2143
- except Exception:
2144
- pass
2145
- renderer.force_repaint(True)
2146
- renderer.render(physical_rows, physical_cols)
2147
-
2148
- master_fd, slave_fd = pty.openpty()
2149
- _set_winsize(slave_fd, child_rows, child_cols)
2150
-
2151
- child = subprocess.Popen(
2152
- [EXEC_TARGET] + sys.argv[1:],
2153
- stdin=slave_fd,
2154
- stdout=slave_fd,
2155
- stderr=slave_fd,
2156
- close_fds=True,
2157
- start_new_session=True,
2158
- )
2159
- os.close(slave_fd)
2160
-
2161
- rewriter = OutputRewriter()
2162
- cursor = CursorTracker()
2163
-
2164
- monitor = None
2165
- try:
2166
- monitor_env = os.environ.copy()
2167
- try:
2168
- monitor_env["DROID_STATUSLINE_PGID"] = str(os.getpgid(child.pid))
2169
- except Exception:
2170
- monitor_env["DROID_STATUSLINE_PGID"] = str(child.pid)
2171
- monitor = subprocess.Popen(
2172
- ["node", STATUSLINE_MONITOR] + sys.argv[1:],
2173
- stdin=subprocess.DEVNULL,
2174
- stdout=subprocess.PIPE,
2175
- stderr=subprocess.DEVNULL,
2176
- close_fds=True,
2177
- bufsize=0,
2178
- env=monitor_env,
2179
- )
2180
- except Exception:
2181
- monitor = None
2182
-
2183
- monitor_fd = monitor.stdout.fileno() if (monitor and monitor.stdout) else None
2184
-
2185
- def forward(sig, _frame):
2186
- try:
2187
- os.killpg(child.pid, sig)
2188
- except Exception:
2189
- pass
2190
-
2191
- for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
2192
- try:
2193
- signal.signal(s, forward)
2194
- except Exception:
2195
- pass
2196
-
2197
- stdin_fd = sys.stdin.fileno()
2198
- stdout_fd = sys.stdout.fileno()
2199
- old_tty = termios.tcgetattr(stdin_fd)
2200
- try:
2201
- tty.setraw(stdin_fd)
2202
- # Ensure stdout is blocking (prevents sporadic EAGAIN/BlockingIOError on some terminals).
2203
- try:
2204
- os.set_blocking(stdout_fd, True)
2205
- except Exception:
2206
- pass
2207
- os.set_blocking(stdin_fd, False)
2208
- os.set_blocking(master_fd, False)
2209
- if monitor_fd is not None:
2210
- os.set_blocking(monitor_fd, False)
2211
-
2212
- monitor_buf = b""
2213
- detect_buf = b""
2214
- cursor_visible = True
2215
- last_physical_rows = 0
2216
- last_physical_cols = 0
2217
- scroll_region_dirty = True
2218
- last_force_repaint_ms = int(time.time() * 1000)
2219
-
2220
- while True:
2221
- if child.poll() is not None:
2222
- break
2223
-
2224
- read_fds = [master_fd, stdin_fd]
2225
- if monitor_fd is not None:
2226
- read_fds.append(monitor_fd)
2227
-
2228
- try:
2229
- rlist, _, _ = select.select(read_fds, [], [], 0.05)
2230
- except InterruptedError:
2231
- rlist = []
2232
-
2233
- pty_eof = False
2234
- for fd in rlist:
2235
- if fd == stdin_fd:
2236
- try:
2237
- data = os.read(stdin_fd, 4096)
2238
- if data:
2239
- os.write(master_fd, data)
2240
- except BlockingIOError:
2241
- pass
2242
- except OSError:
2243
- pass
2244
- elif fd == master_fd:
2245
- try:
2246
- data = os.read(master_fd, 65536)
2247
- except BlockingIOError:
2248
- data = b""
2249
- except OSError:
2250
- data = b""
2251
-
2252
- if data:
2253
- detect_buf = (detect_buf + data)[-128:]
2254
- # Detect sequences that may affect scroll region or clear screen
2255
- needs_scroll_region_reset = (
2256
- (b"\\x1b[?1049" in detect_buf) # Alt screen
2257
- or (b"\\x1b[?1047" in detect_buf) # Alt screen
2258
- or (b"\\x1b[?47" in detect_buf) # Alt screen
2259
- or (b"\\x1b[J" in detect_buf) # Clear below
2260
- or (b"\\x1b[0J" in detect_buf) # Clear below
2261
- or (b"\\x1b[1J" in detect_buf) # Clear above
2262
- or (b"\\x1b[2J" in detect_buf) # Clear all
2263
- or (b"\\x1b[3J" in detect_buf) # Clear scrollback
2264
- or (b"\\x1b[r" in detect_buf) # Reset scroll region (bare ESC[r)
2265
- )
2266
- # Also detect scroll region changes with parameters (DECSTBM pattern ESC[n;mr)
2267
- if b"\\x1b[" in detect_buf and b"r" in detect_buf:
2268
- if re.search(b"\\x1b\\\\[[0-9]*;?[0-9]*r", detect_buf):
2269
- needs_scroll_region_reset = True
2270
- if needs_scroll_region_reset:
2271
- renderer.force_repaint(True)
2272
- scroll_region_dirty = True
2273
- h = detect_buf.rfind(b"\\x1b[?25h")
2274
- l = detect_buf.rfind(b"\\x1b[?25l")
2275
- if h != -1 or l != -1:
2276
- cursor_visible = h > l
2277
- renderer.set_cursor_visible(cursor_visible)
2278
- renderer.note_child_output()
2279
- data = rewriter.feed(data, child_rows)
2280
- cursor.feed(data, child_rows, child_cols)
2281
- try:
2282
- os.write(stdout_fd, data)
2283
- except BlockingIOError:
2284
- # If stdout is non-blocking for some reason, retry briefly.
2285
- try:
2286
- time.sleep(0.01)
2287
- os.write(stdout_fd, data)
2288
- except Exception:
2289
- pass
2290
- except OSError:
2291
- pass
2292
- else:
2293
- pty_eof = True
2294
- elif monitor_fd is not None and fd == monitor_fd:
2295
- try:
2296
- chunk = os.read(monitor_fd, 65536)
2297
- except BlockingIOError:
2298
- chunk = b""
2299
- except OSError:
2300
- chunk = b""
2301
-
2302
- if chunk:
2303
- monitor_buf += chunk
2304
- while True:
2305
- nl = monitor_buf.find(b"\\n")
2306
- if nl == -1:
2307
- break
2308
- raw = monitor_buf[:nl].rstrip(b"\\r")
2309
- monitor_buf = monitor_buf[nl + 1 :]
2310
- if not raw:
2311
- continue
2312
- renderer.set_line(raw.decode("utf-8", "replace"))
2313
- else:
2314
- monitor_fd = None
2315
-
2316
- if pty_eof:
2317
- break
2318
-
2319
- physical_rows, physical_cols = _term_size()
2320
- size_changed = (physical_rows != last_physical_rows) or (physical_cols != last_physical_cols)
2321
-
2322
- desired = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
2323
- if size_changed or (desired != effective_reserved_rows):
2324
- cr, cc = cursor.position()
2325
- if desired < effective_reserved_rows:
2326
- renderer.clear_reserved_area(physical_rows, physical_cols, effective_reserved_rows, cr, cc)
2327
-
2328
- effective_reserved_rows = desired
2329
- renderer.set_active_reserved_rows(effective_reserved_rows)
2330
-
2331
- child_rows = max(4, physical_rows - effective_reserved_rows)
2332
- child_cols = max(10, physical_cols)
2333
- _set_winsize(master_fd, child_rows, child_cols)
2334
- try:
2335
- os.killpg(child.pid, signal.SIGWINCH)
2336
- except Exception:
2337
- pass
2338
-
2339
- scroll_region_dirty = True
2340
- renderer.force_repaint(urgent=True) # Use urgent mode to ensure immediate repaint
2341
- last_physical_rows = physical_rows
2342
- last_physical_cols = physical_cols
2343
-
2344
- cr, cc = cursor.position()
2345
- if cr < 1:
2346
- cr = 1
2347
- if cc < 1:
2348
- cc = 1
2349
- if cr > child_rows:
2350
- cr = child_rows
2351
- if cc > child_cols:
2352
- cc = child_cols
2353
-
2354
- if scroll_region_dirty:
2355
- # Keep the reserved rows out of the terminal scroll region (esp. after resize).
2356
- try:
2357
- seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[{cr};{cc}H"
2358
- seq += "\\x1b[?25h" if cursor_visible else "\\x1b[?25l"
2359
- seq += "\\x1b[?2026l"
2360
- os.write(stdout_fd, seq.encode("utf-8", "ignore"))
2361
- except Exception:
2362
- pass
2363
- scroll_region_dirty = False
2364
-
2365
- # Periodic force repaint to ensure statusline doesn't disappear
2366
- now_ms = int(time.time() * 1000)
2367
- if now_ms - last_force_repaint_ms >= FORCE_REPAINT_INTERVAL_MS:
2368
- renderer.force_repaint(False)
2369
- last_force_repaint_ms = now_ms
2370
-
2371
- renderer.render(physical_rows, physical_cols, cr, cc)
2372
-
2373
- finally:
2374
- try:
2375
- termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty)
2376
- except Exception:
2377
- pass
2378
- try:
2379
- renderer.clear()
2380
- except Exception:
2381
- pass
2382
- try:
2383
- # Restore terminal scroll region and attributes.
2384
- os.write(stdout_fd, b"\\x1b[r\\x1b[0m\\x1b[?25h")
2385
- except Exception:
2386
- pass
2387
- try:
2388
- os.close(master_fd)
2389
- except Exception:
2390
- pass
2391
- if monitor is not None:
2392
- try:
2393
- monitor.terminate()
2394
- except Exception:
2395
- pass
2396
-
2397
- sys.exit(child.returncode or 0)
2398
-
2399
-
2400
- if __name__ == "__main__":
2401
- main()
1732
+ return generateStatuslineWrapperScriptBun(execTargetPath, monitorScriptPath, sessionsScriptPath);
1733
+ }
1734
+ function generateStatuslineWrapperScriptBun(execTargetPath, monitorScriptPath, sessionsScriptPath) {
1735
+ return `#!/usr/bin/env bun
1736
+ // Droid with Statusline (Bun PTY proxy)
1737
+ // Auto-generated by droid-patch --statusline
1738
+
1739
+ const EXEC_TARGET = ${JSON.stringify(execTargetPath)};
1740
+ const STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)};
1741
+ const SESSIONS_SCRIPT = ${sessionsScriptPath ? JSON.stringify(sessionsScriptPath) : "null"};
1742
+
1743
+ const IS_APPLE_TERMINAL = process.env.TERM_PROGRAM === "Apple_Terminal";
1744
+ const MIN_RENDER_INTERVAL_MS = IS_APPLE_TERMINAL ? 800 : 400;
1745
+ const QUIET_MS = 50;
1746
+ const FORCE_REPAINT_INTERVAL_MS = 2000;
1747
+ const RESERVED_ROWS = 1;
1748
+
1749
+ const BYPASS_FLAGS = new Set(["--help", "-h", "--version", "-V"]);
1750
+ const BYPASS_COMMANDS = new Set(["help", "version", "completion", "completions", "exec"]);
1751
+
1752
+ function shouldPassthrough(argv) {
1753
+ for (const a of argv) {
1754
+ if (a === "--") break;
1755
+ if (BYPASS_FLAGS.has(a)) return true;
1756
+ }
1757
+ let endOpts = false;
1758
+ let cmd = null;
1759
+ for (const a of argv) {
1760
+ if (a === "--") {
1761
+ endOpts = true;
1762
+ continue;
1763
+ }
1764
+ if (!endOpts && a.startsWith("-")) continue;
1765
+ cmd = a;
1766
+ break;
1767
+ }
1768
+ return cmd && BYPASS_COMMANDS.has(cmd);
1769
+ }
1770
+
1771
+ function isSessionsCommand(argv) {
1772
+ for (const a of argv) {
1773
+ if (a === "--") return false;
1774
+ if (a === "--sessions") return true;
1775
+ }
1776
+ return false;
1777
+ }
1778
+
1779
+ async function execPassthrough(argv) {
1780
+ const proc = Bun.spawn([EXEC_TARGET, ...argv], {
1781
+ stdin: "inherit",
1782
+ stdout: "inherit",
1783
+ stderr: "inherit",
1784
+ });
1785
+ const code = await proc.exited;
1786
+ process.exit(code ?? 0);
1787
+ }
1788
+
1789
+ async function runSessions() {
1790
+ if (SESSIONS_SCRIPT) {
1791
+ const proc = Bun.spawn(["node", String(SESSIONS_SCRIPT)], {
1792
+ stdin: "inherit",
1793
+ stdout: "inherit",
1794
+ stderr: "inherit",
1795
+ });
1796
+ const code = await proc.exited;
1797
+ process.exit(code ?? 0);
1798
+ }
1799
+ process.stderr.write("[statusline] sessions script not found\\n");
1800
+ process.exit(1);
1801
+ }
1802
+
1803
+ function writeStdout(s) {
1804
+ try {
1805
+ process.stdout.write(s);
1806
+ } catch {
1807
+ // ignore
1808
+ }
1809
+ }
1810
+
1811
+ function termSize() {
1812
+ const rows = Number(process.stdout.rows || 24);
1813
+ const cols = Number(process.stdout.columns || 80);
1814
+ return { rows: Number.isFinite(rows) ? rows : 24, cols: Number.isFinite(cols) ? cols : 80 };
1815
+ }
1816
+
1817
+ const ANSI_RE = /\\x1b\\[[0-9;]*m/g;
1818
+ const RESET_SGR = "\\x1b[0m";
1819
+
1820
+ function visibleWidth(text) {
1821
+ return String(text || "").replace(ANSI_RE, "").length;
1822
+ }
1823
+
1824
+ function clampAnsi(text, cols) {
1825
+ if (!cols || cols <= 0) return String(text || "");
1826
+ cols = cols > 1 ? cols - 1 : cols; // avoid last-column wrap
1827
+ if (cols < 10) return String(text || "");
1828
+ const s = String(text || "");
1829
+ let visible = 0;
1830
+ let i = 0;
1831
+ const out = [];
1832
+ while (i < s.length) {
1833
+ const ch = s[i];
1834
+ if (ch === "\\x1b") {
1835
+ const m = s.indexOf("m", i);
1836
+ if (m !== -1) {
1837
+ out.push(s.slice(i, m + 1));
1838
+ i = m + 1;
1839
+ continue;
1840
+ }
1841
+ out.push(ch);
1842
+ i += 1;
1843
+ continue;
1844
+ }
1845
+ if (visible >= cols) break;
1846
+ out.push(ch);
1847
+ i += 1;
1848
+ visible += 1;
1849
+ }
1850
+ if (i < s.length && cols >= 1) {
1851
+ if (visible >= cols) {
1852
+ if (out.length) out[out.length - 1] = "…";
1853
+ else out.push("…");
1854
+ } else {
1855
+ out.push("");
1856
+ }
1857
+ out.push(RESET_SGR);
1858
+ }
1859
+ return out.join("");
1860
+ }
1861
+
1862
+ function splitSegments(text) {
1863
+ if (!text) return [];
1864
+ const s = String(text);
1865
+ const segments = [];
1866
+ let start = 0;
1867
+ while (true) {
1868
+ const idx = s.indexOf(RESET_SGR, start);
1869
+ if (idx === -1) {
1870
+ const tail = s.slice(start);
1871
+ if (tail) segments.push(tail);
1872
+ break;
1873
+ }
1874
+ const seg = s.slice(start, idx + RESET_SGR.length);
1875
+ if (seg) segments.push(seg);
1876
+ start = idx + RESET_SGR.length;
1877
+ }
1878
+ return segments;
1879
+ }
1880
+
1881
+ function wrapSegments(segments, cols) {
1882
+ if (!segments || segments.length === 0) return [""];
1883
+ if (!cols || cols <= 0) return [segments.join("")];
1884
+
1885
+ const lines = [];
1886
+ let cur = [];
1887
+ let curW = 0;
1888
+
1889
+ for (let seg of segments) {
1890
+ let segW = visibleWidth(seg);
1891
+ if (segW <= 0) continue;
1892
+
1893
+ if (cur.length === 0) {
1894
+ if (segW > cols) {
1895
+ seg = clampAnsi(seg, cols);
1896
+ segW = visibleWidth(seg);
1897
+ }
1898
+ cur = [seg];
1899
+ curW = segW;
1900
+ continue;
1901
+ }
1902
+
1903
+ if (curW + segW <= cols) {
1904
+ cur.push(seg);
1905
+ curW += segW;
1906
+ } else {
1907
+ lines.push(cur.join(""));
1908
+ if (segW > cols) {
1909
+ seg = clampAnsi(seg, cols);
1910
+ segW = visibleWidth(seg);
1911
+ }
1912
+ cur = [seg];
1913
+ curW = segW;
1914
+ }
1915
+ }
1916
+
1917
+ if (cur.length) lines.push(cur.join(""));
1918
+ return lines.length ? lines : [""];
1919
+ }
1920
+
1921
+ class StatusRenderer {
1922
+ constructor() {
1923
+ this.raw = "";
1924
+ this.segments = [];
1925
+ this.lines = [""];
1926
+ this.activeReservedRows = RESERVED_ROWS;
1927
+ this.force = false;
1928
+ this.urgent = false;
1929
+ this.lastRenderMs = 0;
1930
+ this.lastChildOutMs = 0;
1931
+ this.cursorVisible = true;
1932
+ }
1933
+ noteChildOutput() {
1934
+ this.lastChildOutMs = Date.now();
1935
+ }
1936
+ setCursorVisible(v) {
1937
+ this.cursorVisible = !!v;
1938
+ }
1939
+ forceRepaint(urgent = false) {
1940
+ this.force = true;
1941
+ if (urgent) this.urgent = true;
1942
+ }
1943
+ setActiveReservedRows(n) {
1944
+ const v = Number(n || 1);
1945
+ this.activeReservedRows = Number.isFinite(v) ? Math.max(1, Math.trunc(v)) : 1;
1946
+ }
1947
+ setLine(line) {
1948
+ const next = String(line || "");
1949
+ if (next !== this.raw) {
1950
+ this.raw = next;
1951
+ this.segments = splitSegments(next);
1952
+ this.force = true;
1953
+ }
1954
+ }
1955
+ desiredReservedRows(physicalRows, cols, minReserved) {
1956
+ let rows = Number(physicalRows || 24);
1957
+ rows = Number.isFinite(rows) ? rows : 24;
1958
+ cols = Number(cols || 80);
1959
+ cols = Number.isFinite(cols) ? cols : 80;
1960
+
1961
+ const maxReserved = Math.max(1, rows - 4);
1962
+ const segs = this.segments.length ? this.segments : (this.raw ? [this.raw] : []);
1963
+ let lines = segs.length ? wrapSegments(segs, cols) : [""];
1964
+
1965
+ const needed = Math.min(lines.length, maxReserved);
1966
+ let desired = Math.max(Number(minReserved || 1), needed);
1967
+ desired = Math.min(desired, maxReserved);
1968
+
1969
+ if (lines.length < desired) lines = new Array(desired - lines.length).fill("").concat(lines);
1970
+ if (lines.length > desired) lines = lines.slice(-desired);
1971
+
1972
+ this.lines = lines;
1973
+ return desired;
1974
+ }
1975
+ clearReservedArea(physicalRows, cols, reservedRows, restoreRow = 1, restoreCol = 1) {
1976
+ let rows = Number(physicalRows || 24);
1977
+ rows = Number.isFinite(rows) ? rows : 24;
1978
+ cols = Number(cols || 80);
1979
+ cols = Number.isFinite(cols) ? cols : 80;
1980
+ let reserved = Number(reservedRows || 1);
1981
+ reserved = Number.isFinite(reserved) ? Math.max(1, Math.trunc(reserved)) : 1;
1982
+
1983
+ reserved = Math.min(reserved, rows);
1984
+ const startRow = rows - reserved + 1;
1985
+ const parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR];
1986
+ for (let i = 0; i < reserved; i++) parts.push("\\x1b[" + (startRow + i) + ";1H\\x1b[2K");
1987
+ parts.push("\\x1b[" + restoreRow + ";" + restoreCol + "H");
1988
+ parts.push(this.cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l");
1989
+ parts.push("\\x1b[?2026l");
1990
+ writeStdout(parts.join(""));
1991
+ }
1992
+ render(physicalRows, cols, restoreRow = 1, restoreCol = 1) {
1993
+ if (!this.force) return;
1994
+ if (!this.raw) {
1995
+ this.force = false;
1996
+ this.urgent = false;
1997
+ return;
1998
+ }
1999
+ const now = Date.now();
2000
+ if (!this.urgent && now - this.lastRenderMs < MIN_RENDER_INTERVAL_MS) return;
2001
+ if (!this.urgent && QUIET_MS > 0 && now - this.lastChildOutMs < QUIET_MS) return;
2002
+
2003
+ let rows = Number(physicalRows || 24);
2004
+ rows = Number.isFinite(rows) ? rows : 24;
2005
+ cols = Number(cols || 80);
2006
+ cols = Number.isFinite(cols) ? cols : 80;
2007
+ if (cols <= 0) cols = 80;
2008
+
2009
+ const reserved = Math.max(1, Math.min(this.activeReservedRows, Math.max(1, rows - 4)));
2010
+ const startRow = rows - reserved + 1;
2011
+ const childRows = rows - reserved;
2012
+
2013
+ let lines = this.lines.length ? this.lines.slice() : [""];
2014
+ if (lines.length < reserved) lines = new Array(reserved - lines.length).fill("").concat(lines);
2015
+ if (lines.length > reserved) lines = lines.slice(-reserved);
2016
+
2017
+ const parts = ["\\x1b[?2026h", "\\x1b[?25l"];
2018
+ parts.push("\\x1b[1;" + childRows + "r");
2019
+ for (let i = 0; i < reserved; i++) {
2020
+ const row = startRow + i;
2021
+ const text = clampAnsi(lines[i], cols);
2022
+ parts.push("\\x1b[" + row + ";1H" + RESET_SGR + "\\x1b[2K");
2023
+ parts.push("\\x1b[" + row + ";1H" + text + RESET_SGR);
2024
+ }
2025
+ parts.push("\\x1b[" + restoreRow + ";" + restoreCol + "H");
2026
+ parts.push(this.cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l");
2027
+ parts.push("\\x1b[?2026l");
2028
+ writeStdout(parts.join(""));
2029
+
2030
+ this.lastRenderMs = now;
2031
+ this.force = false;
2032
+ this.urgent = false;
2033
+ }
2034
+ clear() {
2035
+ const { rows, cols } = termSize();
2036
+ this.clearReservedArea(rows, cols, Math.max(this.activeReservedRows, RESERVED_ROWS));
2037
+ }
2038
+ }
2039
+
2040
+ class OutputRewriter {
2041
+ constructor() {
2042
+ this.buf = new Uint8Array(0);
2043
+ }
2044
+ feed(chunk, maxRow) {
2045
+ if (!chunk || chunk.length === 0) return chunk;
2046
+ const merged = new Uint8Array(this.buf.length + chunk.length);
2047
+ merged.set(this.buf, 0);
2048
+ merged.set(chunk, this.buf.length);
2049
+ this.buf = new Uint8Array(0);
2050
+
2051
+ const out = [];
2052
+ let i = 0;
2053
+
2054
+ const isFinal = (v) => v >= 0x40 && v <= 0x7e;
2055
+
2056
+ while (i < merged.length) {
2057
+ const b = merged[i];
2058
+ if (b !== 0x1b) {
2059
+ out.push(b);
2060
+ i += 1;
2061
+ continue;
2062
+ }
2063
+ if (i + 1 >= merged.length) {
2064
+ this.buf = merged.slice(i);
2065
+ break;
2066
+ }
2067
+ const nxt = merged[i + 1];
2068
+ if (nxt !== 0x5b) {
2069
+ out.push(b);
2070
+ i += 1;
2071
+ continue;
2072
+ }
2073
+
2074
+ let j = i + 2;
2075
+ while (j < merged.length && !isFinal(merged[j])) j += 1;
2076
+ if (j >= merged.length) {
2077
+ this.buf = merged.slice(i);
2078
+ break;
2079
+ }
2080
+ const final = merged[j];
2081
+ let seq = merged.slice(i, j + 1);
2082
+
2083
+ if ((final === 0x48 || final === 0x66) && maxRow > 0) {
2084
+ const params = merged.slice(i + 2, j);
2085
+ const s = new TextDecoder().decode(params);
2086
+ if (!s || /^[0-9;]/.test(s)) {
2087
+ const parts = s ? s.split(";") : [];
2088
+ const row = Number(parts[0] || 1);
2089
+ const col = Number(parts[1] || 1);
2090
+ let r = Number.isFinite(row) ? row : 1;
2091
+ let c = Number.isFinite(col) ? col : 1;
2092
+ if (r === 999 || r > maxRow) r = maxRow;
2093
+ if (r < 1) r = 1;
2094
+ if (c < 1) c = 1;
2095
+ const newParams = new TextEncoder().encode(String(r) + ";" + String(c));
2096
+ const ns = new Uint8Array(2 + newParams.length + 1);
2097
+ ns[0] = 0x1b;
2098
+ ns[1] = 0x5b;
2099
+ ns.set(newParams, 2);
2100
+ ns[ns.length - 1] = final;
2101
+ seq = ns;
2102
+ }
2103
+ } else if (final === 0x72 && maxRow > 0) {
2104
+ const params = merged.slice(i + 2, j);
2105
+ const s = new TextDecoder().decode(params);
2106
+ if (!s || /^[0-9;]/.test(s)) {
2107
+ const parts = s ? s.split(";") : [];
2108
+ const top = Number(parts[0] || 1);
2109
+ const bottom = Number(parts[1] || maxRow);
2110
+ let t = Number.isFinite(top) ? top : 1;
2111
+ let btm = Number.isFinite(bottom) ? bottom : maxRow;
2112
+ if (t <= 0) t = 1;
2113
+ if (btm <= 0 || btm === 999 || btm > maxRow) btm = maxRow;
2114
+ if (t > btm) t = 1;
2115
+ const str = "\\x1b[" + String(t) + ";" + String(btm) + "r";
2116
+ seq = new TextEncoder().encode(str);
2117
+ }
2118
+ }
2119
+
2120
+ for (const bb of seq) out.push(bb);
2121
+ i = j + 1;
2122
+ }
2123
+
2124
+ return new Uint8Array(out);
2125
+ }
2126
+ }
2127
+
2128
+ class CursorTracker {
2129
+ constructor() {
2130
+ this.row = 1;
2131
+ this.col = 1;
2132
+ this.savedRow = 1;
2133
+ this.savedCol = 1;
2134
+ this.buf = new Uint8Array(0);
2135
+ this.inOsc = false;
2136
+ this.utf8Cont = 0;
2137
+ this.wrapPending = false;
2138
+ }
2139
+ position() {
2140
+ return { row: this.row, col: this.col };
2141
+ }
2142
+ feed(chunk, maxRow, maxCol) {
2143
+ if (!chunk || chunk.length === 0) return;
2144
+ maxRow = Math.max(1, Number(maxRow || 1));
2145
+ maxCol = Math.max(1, Number(maxCol || 1));
2146
+
2147
+ const merged = new Uint8Array(this.buf.length + chunk.length);
2148
+ merged.set(this.buf, 0);
2149
+ merged.set(chunk, this.buf.length);
2150
+ this.buf = new Uint8Array(0);
2151
+
2152
+ const clamp = () => {
2153
+ if (this.row < 1) this.row = 1;
2154
+ else if (this.row > maxRow) this.row = maxRow;
2155
+ if (this.col < 1) this.col = 1;
2156
+ else if (this.col > maxCol) this.col = maxCol;
2157
+ };
2158
+
2159
+ const parseIntDefault = (v, d) => {
2160
+ const n = Number(v);
2161
+ return Number.isFinite(n) && n > 0 ? Math.trunc(n) : d;
2162
+ };
2163
+
2164
+ let i = 0;
2165
+ const isFinal = (v) => v >= 0x40 && v <= 0x7e;
2166
+
2167
+ while (i < merged.length) {
2168
+ const b = merged[i];
2169
+
2170
+ if (this.inOsc) {
2171
+ if (b === 0x07) {
2172
+ this.inOsc = false;
2173
+ i += 1;
2174
+ continue;
2175
+ }
2176
+ if (b === 0x1b) {
2177
+ if (i + 1 >= merged.length) {
2178
+ this.buf = merged.slice(i);
2179
+ break;
2180
+ }
2181
+ if (merged[i + 1] === 0x5c) {
2182
+ this.inOsc = false;
2183
+ i += 2;
2184
+ continue;
2185
+ }
2186
+ }
2187
+ i += 1;
2188
+ continue;
2189
+ }
2190
+
2191
+ if (this.utf8Cont > 0) {
2192
+ if (b >= 0x80 && b <= 0xbf) {
2193
+ this.utf8Cont -= 1;
2194
+ i += 1;
2195
+ continue;
2196
+ }
2197
+ this.utf8Cont = 0;
2198
+ }
2199
+
2200
+ if (b === 0x1b) {
2201
+ this.wrapPending = false;
2202
+ if (i + 1 >= merged.length) {
2203
+ this.buf = merged.slice(i);
2204
+ break;
2205
+ }
2206
+ const nxt = merged[i + 1];
2207
+
2208
+ if (nxt === 0x5b) {
2209
+ let j = i + 2;
2210
+ while (j < merged.length && !isFinal(merged[j])) j += 1;
2211
+ if (j >= merged.length) {
2212
+ this.buf = merged.slice(i);
2213
+ break;
2214
+ }
2215
+ const final = merged[j];
2216
+ const params = merged.slice(i + 2, j);
2217
+ const s = new TextDecoder().decode(params);
2218
+ if (s && !/^[0-9;]/.test(s)) {
2219
+ i = j + 1;
2220
+ continue;
2221
+ }
2222
+ const parts = s ? s.split(";") : [];
2223
+ const p0 = parseIntDefault(parts[0] || "", 1);
2224
+ const p1 = parseIntDefault(parts[1] || "", 1);
2225
+
2226
+ if (final === 0x48 || final === 0x66) {
2227
+ this.row = p0;
2228
+ this.col = p1;
2229
+ clamp();
2230
+ } else if (final === 0x41) {
2231
+ this.row = Math.max(1, this.row - p0);
2232
+ } else if (final === 0x42) {
2233
+ this.row = Math.min(maxRow, this.row + p0);
2234
+ } else if (final === 0x43) {
2235
+ this.col = Math.min(maxCol, this.col + p0);
2236
+ } else if (final === 0x44) {
2237
+ this.col = Math.max(1, this.col - p0);
2238
+ } else if (final === 0x45) {
2239
+ this.row = Math.min(maxRow, this.row + p0);
2240
+ this.col = 1;
2241
+ } else if (final === 0x46) {
2242
+ this.row = Math.max(1, this.row - p0);
2243
+ this.col = 1;
2244
+ } else if (final === 0x47) {
2245
+ this.col = p0;
2246
+ clamp();
2247
+ } else if (final === 0x64) {
2248
+ this.row = p0;
2249
+ clamp();
2250
+ } else if (final === 0x72) {
2251
+ this.row = 1;
2252
+ this.col = 1;
2253
+ } else if (final === 0x73) {
2254
+ this.savedRow = this.row;
2255
+ this.savedCol = this.col;
2256
+ } else if (final === 0x75) {
2257
+ this.row = this.savedRow;
2258
+ this.col = this.savedCol;
2259
+ clamp();
2260
+ }
2261
+
2262
+ i = j + 1;
2263
+ continue;
2264
+ }
2265
+
2266
+ if (nxt === 0x5d || nxt === 0x50 || nxt === 0x5e || nxt === 0x5f || nxt === 0x58) {
2267
+ this.inOsc = true;
2268
+ i += 2;
2269
+ continue;
2270
+ }
2271
+
2272
+ if (nxt === 0x37) {
2273
+ this.savedRow = this.row;
2274
+ this.savedCol = this.col;
2275
+ i += 2;
2276
+ continue;
2277
+ }
2278
+ if (nxt === 0x38) {
2279
+ this.row = this.savedRow;
2280
+ this.col = this.savedCol;
2281
+ clamp();
2282
+ i += 2;
2283
+ continue;
2284
+ }
2285
+
2286
+ i += 2;
2287
+ continue;
2288
+ }
2289
+
2290
+ if (b === 0x0d) {
2291
+ this.col = 1;
2292
+ this.wrapPending = false;
2293
+ i += 1;
2294
+ continue;
2295
+ }
2296
+ if (b === 0x0a || b === 0x0b || b === 0x0c) {
2297
+ this.row = Math.min(maxRow, this.row + 1);
2298
+ this.wrapPending = false;
2299
+ i += 1;
2300
+ continue;
2301
+ }
2302
+ if (b === 0x08) {
2303
+ this.col = Math.max(1, this.col - 1);
2304
+ this.wrapPending = false;
2305
+ i += 1;
2306
+ continue;
2307
+ }
2308
+ if (b === 0x09) {
2309
+ const nextStop = Math.floor((this.col - 1) / 8 + 1) * 8 + 1;
2310
+ this.col = Math.min(maxCol, nextStop);
2311
+ this.wrapPending = false;
2312
+ i += 1;
2313
+ continue;
2314
+ }
2315
+ if (b < 0x20 || b === 0x7f) {
2316
+ i += 1;
2317
+ continue;
2318
+ }
2319
+
2320
+ if (this.wrapPending) {
2321
+ this.row = Math.min(maxRow, this.row + 1);
2322
+ this.col = 1;
2323
+ this.wrapPending = false;
2324
+ }
2325
+
2326
+ if (b >= 0x80) {
2327
+ if ((b & 0xe0) === 0xc0) this.utf8Cont = 1;
2328
+ else if ((b & 0xf0) === 0xe0) this.utf8Cont = 2;
2329
+ else if ((b & 0xf8) === 0xf0) this.utf8Cont = 3;
2330
+ else this.utf8Cont = 0;
2331
+ }
2332
+
2333
+ if (this.col < maxCol) this.col += 1;
2334
+ else {
2335
+ this.col = maxCol;
2336
+ this.wrapPending = true;
2337
+ }
2338
+ i += 1;
2339
+ }
2340
+ }
2341
+ }
2342
+
2343
+ async function main() {
2344
+ const argv = process.argv.slice(2);
2345
+
2346
+ if (isSessionsCommand(argv)) await runSessions();
2347
+
2348
+ if (!process.stdin.isTTY || !process.stdout.isTTY || shouldPassthrough(argv)) {
2349
+ await execPassthrough(argv);
2350
+ return;
2351
+ }
2352
+
2353
+ // Clean viewport.
2354
+ writeStdout("\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l");
2355
+
2356
+ const renderer = new StatusRenderer();
2357
+ renderer.setLine("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m");
2358
+ renderer.forceRepaint(true);
2359
+
2360
+ let { rows: physicalRows, cols: physicalCols } = termSize();
2361
+ let effectiveReservedRows = renderer.desiredReservedRows(physicalRows, physicalCols, RESERVED_ROWS);
2362
+ renderer.setActiveReservedRows(effectiveReservedRows);
2363
+ let childRows = Math.max(4, physicalRows - effectiveReservedRows);
2364
+ let childCols = Math.max(10, physicalCols);
2365
+
2366
+ // Reserve the bottom rows early, before the child starts writing.
2367
+ writeStdout(
2368
+ "\\x1b[?2026h\\x1b[?25l\\x1b[1;" + childRows + "r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l",
2369
+ );
2370
+ renderer.forceRepaint(true);
2371
+ renderer.render(physicalRows, physicalCols, 1, 1);
2372
+
2373
+ // Spawn child with terminal support.
2374
+ let child;
2375
+ try {
2376
+ child = Bun.spawn([EXEC_TARGET, ...argv], {
2377
+ cwd: process.cwd(),
2378
+ env: process.env,
2379
+ detached: true,
2380
+ terminal: {
2381
+ cols: childCols,
2382
+ rows: childRows,
2383
+ data(_terminal, data) {
2384
+ onChildData(data);
2385
+ },
2386
+ },
2387
+ onExit(_proc, exitCode, signal, _error) {
2388
+ onChildExit(exitCode, signal);
2389
+ },
2390
+ });
2391
+ } catch (e) {
2392
+ process.stderr.write("[statusline] failed to spawn child: " + String(e?.message || e) + "\\n");
2393
+ process.exit(1);
2394
+ }
2395
+
2396
+ const terminal = child.terminal;
2397
+
2398
+ // Best-effort PGID resolution (matches Python wrapper behavior).
2399
+ // This improves session resolution (ps/lsof scanning) and signal forwarding.
2400
+ let pgid = child.pid;
2401
+ try {
2402
+ const res = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(child.pid)], {
2403
+ stdin: "ignore",
2404
+ stdout: "pipe",
2405
+ stderr: "ignore",
2406
+ });
2407
+ if (res && res.exitCode === 0 && res.stdout) {
2408
+ const text = new TextDecoder().decode(res.stdout).trim();
2409
+ const n = Number(text);
2410
+ if (Number.isFinite(n) && n > 0) pgid = Math.trunc(n);
2411
+ }
2412
+ } catch {}
2413
+
2414
+ // Spawn monitor (Node).
2415
+ const monitorEnv = { ...process.env, DROID_STATUSLINE_PGID: String(pgid) };
2416
+ const monitor = Bun.spawn(["node", STATUSLINE_MONITOR, ...argv], {
2417
+ stdin: "ignore",
2418
+ stdout: "pipe",
2419
+ stderr: "ignore",
2420
+ env: monitorEnv,
2421
+ });
2422
+
2423
+ let shouldStop = false;
2424
+ const rewriter = new OutputRewriter();
2425
+ const cursor = new CursorTracker();
2426
+
2427
+ let detectBuf = new Uint8Array(0);
2428
+ let detectStr = "";
2429
+ let cursorVisible = true;
2430
+ let scrollRegionDirty = true;
2431
+ let lastForceRepaintMs = Date.now();
2432
+ let lastPhysicalRows = 0;
2433
+ let lastPhysicalCols = 0;
2434
+
2435
+ function appendDetect(chunk) {
2436
+ const max = 128;
2437
+ const merged = new Uint8Array(Math.min(max, detectBuf.length + chunk.length));
2438
+ const takePrev = Math.max(0, merged.length - chunk.length);
2439
+ if (takePrev > 0) merged.set(detectBuf.slice(Math.max(0, detectBuf.length - takePrev)), 0);
2440
+ merged.set(chunk.slice(Math.max(0, chunk.length - (merged.length - takePrev))), takePrev);
2441
+ detectBuf = merged;
2442
+ try {
2443
+ detectStr = Buffer.from(detectBuf).toString("latin1");
2444
+ } catch {
2445
+ detectStr = "";
2446
+ }
2447
+ }
2448
+
2449
+ function includesBytes(needle) {
2450
+ return detectStr.includes(needle);
2451
+ }
2452
+
2453
+ function lastIndexOfBytes(needle) {
2454
+ return detectStr.lastIndexOf(needle);
2455
+ }
2456
+
2457
+ function includesScrollRegionCSI() {
2458
+ return /\\x1b\\[[0-9]*;?[0-9]*r/.test(detectStr);
2459
+ }
2460
+
2461
+ function updateCursorVisibility() {
2462
+ const show = includesBytes("\\x1b[?25h");
2463
+ const hide = includesBytes("\\x1b[?25l");
2464
+ if (show || hide) {
2465
+ // best-effort: if both present, whichever appears later "wins"
2466
+ const h = lastIndexOfBytes("\\x1b[?25h");
2467
+ const l = lastIndexOfBytes("\\x1b[?25l");
2468
+ cursorVisible = h > l;
2469
+ renderer.setCursorVisible(cursorVisible);
2470
+ }
2471
+ }
2472
+
2473
+ function needsScrollRegionReset() {
2474
+ return (
2475
+ includesBytes("\\x1b[?1049") ||
2476
+ includesBytes("\\x1b[?1047") ||
2477
+ includesBytes("\\x1b[?47") ||
2478
+ includesBytes("\\x1b[J") ||
2479
+ includesBytes("\\x1b[0J") ||
2480
+ includesBytes("\\x1b[1J") ||
2481
+ includesBytes("\\x1b[2J") ||
2482
+ includesBytes("\\x1b[3J") ||
2483
+ includesBytes("\\x1b[r") ||
2484
+ includesScrollRegionCSI()
2485
+ );
2486
+ }
2487
+
2488
+ function onChildData(data) {
2489
+ if (shouldStop) return;
2490
+ const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
2491
+ appendDetect(chunk);
2492
+ if (needsScrollRegionReset()) scrollRegionDirty = true;
2493
+ updateCursorVisibility();
2494
+
2495
+ renderer.noteChildOutput();
2496
+ const rewritten = rewriter.feed(chunk, childRows);
2497
+ cursor.feed(rewritten, childRows, childCols);
2498
+ writeStdout(Buffer.from(rewritten));
2499
+ }
2500
+
2501
+ function onChildExit(exitCode, signal) {
2502
+ if (shouldStop) return;
2503
+ shouldStop = true;
2504
+ const code = exitCode ?? (signal != null ? 128 + signal : 0);
2505
+ cleanup().finally(() => process.exit(code));
2506
+ }
2507
+
2508
+ async function readMonitor() {
2509
+ if (!monitor.stdout) return;
2510
+ const reader = monitor.stdout.getReader();
2511
+ let buf = "";
2512
+ while (!shouldStop) {
2513
+ const { value, done } = await reader.read();
2514
+ if (done || !value) break;
2515
+ buf += new TextDecoder().decode(value);
2516
+ while (true) {
2517
+ const idx = buf.indexOf("\\n");
2518
+ if (idx === -1) break;
2519
+ const line = buf.slice(0, idx).replace(/\\r$/, "");
2520
+ buf = buf.slice(idx + 1);
2521
+ if (!line) continue;
2522
+ renderer.setLine(line);
2523
+ renderer.forceRepaint(false);
2524
+ }
2525
+ }
2526
+ }
2527
+ readMonitor().catch(() => {});
2528
+
2529
+ function repaintStatusline(forceUrgent = false) {
2530
+ const { row, col } = cursor.position();
2531
+ let r = Math.max(1, Math.min(childRows, row));
2532
+ let c = Math.max(1, Math.min(childCols, col));
2533
+
2534
+ if (scrollRegionDirty) {
2535
+ const seq =
2536
+ "\\x1b[?2026h\\x1b[?25l\\x1b[1;" +
2537
+ childRows +
2538
+ "r\\x1b[" +
2539
+ r +
2540
+ ";" +
2541
+ c +
2542
+ "H" +
2543
+ (cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l") +
2544
+ "\\x1b[?2026l";
2545
+ writeStdout(seq);
2546
+ scrollRegionDirty = false;
2547
+ }
2548
+
2549
+ renderer.forceRepaint(forceUrgent);
2550
+ renderer.render(physicalRows, physicalCols, r, c);
2551
+ }
2552
+
2553
+ function handleSizeChange(nextRows, nextCols, forceUrgent = false) {
2554
+ physicalRows = nextRows;
2555
+ physicalCols = nextCols;
2556
+
2557
+ const desired = renderer.desiredReservedRows(physicalRows, physicalCols, RESERVED_ROWS);
2558
+ const { row, col } = cursor.position();
2559
+ if (desired < effectiveReservedRows) {
2560
+ renderer.clearReservedArea(physicalRows, physicalCols, effectiveReservedRows, row, col);
2561
+ }
2562
+ effectiveReservedRows = desired;
2563
+ renderer.setActiveReservedRows(effectiveReservedRows);
2564
+
2565
+ childRows = Math.max(4, physicalRows - effectiveReservedRows);
2566
+ childCols = Math.max(10, physicalCols);
2567
+ try {
2568
+ terminal.resize(childCols, childRows);
2569
+ } catch {}
2570
+ try {
2571
+ process.kill(-child.pid, "SIGWINCH");
2572
+ } catch {
2573
+ try { process.kill(child.pid, "SIGWINCH"); } catch {}
2574
+ }
2575
+
2576
+ scrollRegionDirty = true;
2577
+ renderer.forceRepaint(true);
2578
+ repaintStatusline(forceUrgent);
2579
+ }
2580
+
2581
+ process.on("SIGWINCH", () => {
2582
+ const next = termSize();
2583
+ handleSizeChange(next.rows, next.cols, true);
2584
+ });
2585
+
2586
+ // Forward signals to child's process group when possible.
2587
+ const forward = (sig) => {
2588
+ try {
2589
+ process.kill(-pgid, sig);
2590
+ } catch {
2591
+ try {
2592
+ process.kill(child.pid, sig);
2593
+ } catch {}
2594
+ }
2595
+ };
2596
+ for (const s of ["SIGTERM", "SIGINT", "SIGHUP"]) {
2597
+ try {
2598
+ process.on(s, () => forward(s));
2599
+ } catch {}
2600
+ }
2601
+
2602
+ // Raw stdin -> PTY.
2603
+ try {
2604
+ process.stdin.setRawMode(true);
2605
+ } catch {}
2606
+ process.stdin.resume();
2607
+ process.stdin.on("data", (buf) => {
2608
+ try {
2609
+ if (typeof buf === "string") terminal.write(buf);
2610
+ else {
2611
+ // Prefer bytes when supported; fall back to UTF-8 decoding.
2612
+ try {
2613
+ // Bun.Terminal.write may accept Uint8Array in newer versions.
2614
+ terminal.write(buf);
2615
+ } catch {
2616
+ terminal.write(new TextDecoder().decode(buf));
2617
+ }
2618
+ }
2619
+ } catch {}
2620
+ });
2621
+
2622
+ const tick = setInterval(() => {
2623
+ if (shouldStop) return;
2624
+ const next = termSize();
2625
+ const sizeChanged = next.rows !== lastPhysicalRows || next.cols !== lastPhysicalCols;
2626
+ const desired = renderer.desiredReservedRows(next.rows, next.cols, RESERVED_ROWS);
2627
+ if (sizeChanged || desired !== effectiveReservedRows) {
2628
+ handleSizeChange(next.rows, next.cols, true);
2629
+ lastPhysicalRows = next.rows;
2630
+ lastPhysicalCols = next.cols;
2631
+ lastForceRepaintMs = Date.now();
2632
+ return;
2633
+ }
2634
+ const now = Date.now();
2635
+ if (now - lastForceRepaintMs >= FORCE_REPAINT_INTERVAL_MS) {
2636
+ repaintStatusline(false);
2637
+ lastForceRepaintMs = now;
2638
+ } else {
2639
+ const { row, col } = cursor.position();
2640
+ renderer.render(physicalRows, physicalCols, row, col);
2641
+ }
2642
+ }, 50);
2643
+
2644
+ async function cleanup() {
2645
+ clearInterval(tick);
2646
+ try {
2647
+ process.stdin.setRawMode(false);
2648
+ } catch {}
2649
+ try {
2650
+ const { row, col } = cursor.position();
2651
+ renderer.clearReservedArea(physicalRows, physicalCols, effectiveReservedRows, row, col);
2652
+ } catch {}
2653
+ try {
2654
+ writeStdout("\\x1b[r\\x1b[0m\\x1b[?25h");
2655
+ } catch {}
2656
+ try {
2657
+ monitor.kill();
2658
+ } catch {}
2659
+ try {
2660
+ terminal.close();
2661
+ } catch {}
2662
+ }
2663
+
2664
+ // Keep process alive until child exits.
2665
+ await child.exited;
2666
+ await cleanup();
2667
+ }
2668
+
2669
+ main().catch(() => process.exit(1));
2402
2670
  `;
2403
2671
  }
2404
2672
  async function createStatuslineFiles(outputDir, execTargetPath, aliasName, sessionsScriptPath) {
@@ -2613,27 +2881,49 @@ async function main() {
2613
2881
  let selected = 0;
2614
2882
  let offset = 0;
2615
2883
 
2884
+ function restoreTerminal() {
2885
+ try { process.stdout.write(SHOW_CURSOR); } catch {}
2886
+ try { process.stdin.setRawMode(false); } catch {}
2887
+ try { process.stdin.pause(); } catch {}
2888
+ }
2889
+
2890
+ function clearScreen() {
2891
+ try { process.stdout.write(CLEAR); } catch {}
2892
+ }
2893
+
2616
2894
  process.stdin.setRawMode(true);
2617
2895
  process.stdin.resume();
2618
2896
  process.stdout.write(HIDE_CURSOR);
2619
2897
 
2620
2898
  render(sessions, selected, offset, rows);
2621
2899
 
2622
- process.stdin.on('data', (key) => {
2900
+ const onKey = (key) => {
2623
2901
  const k = key.toString();
2624
2902
 
2625
2903
  if (k === 'q' || k === '\\x03') { // q or Ctrl+C
2626
- process.stdout.write(SHOW_CURSOR + CLEAR);
2904
+ restoreTerminal();
2905
+ clearScreen();
2627
2906
  process.exit(0);
2628
2907
  }
2629
2908
 
2630
2909
  if (k === '\\r' || k === '\\n') { // Enter
2631
- process.stdout.write(SHOW_CURSOR + CLEAR);
2910
+ // Stop reading input / stop reacting to arrow keys before handing off to droid.
2911
+ process.stdin.off('data', onKey);
2912
+ restoreTerminal();
2913
+ clearScreen();
2632
2914
  const session = sessions[selected];
2633
2915
  console.log(GREEN + 'Resuming session: ' + session.id + RESET);
2634
2916
  console.log(DIM + 'Using: ' + ALIAS_NAME + ' --resume ' + session.id + RESET + '\\n');
2917
+
2918
+ // Avoid the sessions browser reacting to signals while droid is running.
2919
+ try { process.removeAllListeners('SIGINT'); } catch {}
2920
+ try { process.removeAllListeners('SIGTERM'); } catch {}
2921
+ try { process.on('SIGINT', () => {}); } catch {}
2922
+ try { process.on('SIGTERM', () => {}); } catch {}
2923
+
2635
2924
  const child = spawn(ALIAS_NAME, ['--resume', session.id], { stdio: 'inherit' });
2636
2925
  child.on('exit', (code) => process.exit(code || 0));
2926
+ child.on('error', () => process.exit(1));
2637
2927
  return;
2638
2928
  }
2639
2929
 
@@ -2656,10 +2946,13 @@ async function main() {
2656
2946
  }
2657
2947
 
2658
2948
  render(sessions, selected, offset, rows);
2659
- });
2949
+ };
2950
+
2951
+ process.stdin.on('data', onKey);
2660
2952
 
2661
2953
  process.on('SIGINT', () => {
2662
- process.stdout.write(SHOW_CURSOR + CLEAR);
2954
+ restoreTerminal();
2955
+ clearScreen();
2663
2956
  process.exit(0);
2664
2957
  });
2665
2958
  }
@@ -2748,7 +3041,9 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2748
3041
  const backup = options.backup !== false;
2749
3042
  const verbose = options.verbose;
2750
3043
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
2751
- if (!(!!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch) && (websearch || statusline)) {
3044
+ const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch;
3045
+ const statuslineEnabled = statusline;
3046
+ if (!needsBinaryPatch && (websearch || statuslineEnabled)) {
2752
3047
  if (!alias) {
2753
3048
  console.log(styleText("red", "Error: Alias name required for --websearch/--statusline"));
2754
3049
  console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
@@ -2764,14 +3059,14 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2764
3059
  console.log(styleText("white", `Forward target: ${websearchTarget}`));
2765
3060
  if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
2766
3061
  }
2767
- if (statusline) console.log(styleText("white", `Statusline: enabled`));
3062
+ if (statuslineEnabled) console.log(styleText("white", `Statusline: enabled`));
2768
3063
  console.log();
2769
3064
  let execTargetPath = path;
2770
3065
  if (websearch) {
2771
3066
  const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
2772
3067
  execTargetPath = wrapperScript;
2773
3068
  }
2774
- if (statusline) {
3069
+ if (statuslineEnabled) {
2775
3070
  const statuslineDir = join(homedir(), ".droid-patch", "statusline");
2776
3071
  let sessionsScript;
2777
3072
  if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
@@ -2785,7 +3080,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2785
3080
  skipLogin: false,
2786
3081
  apiBase: apiBase || null,
2787
3082
  websearch: !!websearch,
2788
- statusline: !!statusline,
3083
+ statusline: !!statuslineEnabled,
2789
3084
  sessions: !!sessions,
2790
3085
  reasoningEffort: false,
2791
3086
  noTelemetry: false,
@@ -2822,7 +3117,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2822
3117
  }
2823
3118
  return;
2824
3119
  }
2825
- if (!isCustom && !skipLogin && !apiBase && !websearch && !statusline && !reasoningEffort && !noTelemetry) {
3120
+ if (!isCustom && !skipLogin && !apiBase && !websearch && !statuslineEnabled && !reasoningEffort && !noTelemetry) {
2826
3121
  console.log(styleText("yellow", "No patch flags specified. Available patches:"));
2827
3122
  console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
2828
3123
  console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
@@ -2981,7 +3276,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2981
3276
  console.log(styleText("white", ` Forward target: ${websearchTarget}`));
2982
3277
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
2983
3278
  }
2984
- if (statusline) {
3279
+ if (statuslineEnabled) {
2985
3280
  const statuslineDir = join(homedir(), ".droid-patch", "statusline");
2986
3281
  let sessionsScript;
2987
3282
  if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
@@ -2991,7 +3286,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2991
3286
  console.log(styleText("cyan", "Statusline enabled"));
2992
3287
  }
2993
3288
  let aliasResult;
2994
- if (websearch || statusline) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
3289
+ if (websearch || statuslineEnabled) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
2995
3290
  else aliasResult = await createAlias(result.outputPath, alias, verbose);
2996
3291
  const droidVersion = getDroidVersion(path);
2997
3292
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -2999,7 +3294,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2999
3294
  skipLogin: !!skipLogin,
3000
3295
  apiBase: apiBase || null,
3001
3296
  websearch: !!websearch,
3002
- statusline: !!statusline,
3297
+ statusline: !!statuslineEnabled,
3003
3298
  sessions: !!sessions,
3004
3299
  reasoningEffort: !!reasoningEffort,
3005
3300
  noTelemetry: !!noTelemetry,