droid-patch 0.8.0 → 0.8.1

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,28 @@ 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 nextCompactionState(line, current) {
710
+ if (!line) return current;
711
+ if (line.includes('[Compaction] Start')) return true;
712
+ if (
713
+ line.includes('[Compaction] End') ||
714
+ line.includes('[Compaction] Done') ||
715
+ line.includes('[Compaction] Finish') ||
716
+ line.includes('[Compaction] Finished') ||
717
+ line.includes('[Compaction] Complete') ||
718
+ line.includes('[Compaction] Completed')
719
+ ) {
720
+ return false;
721
+ }
722
+ return current;
723
+ }
724
+
702
725
  function firstNonNull(promises) {
703
726
  const list = Array.isArray(promises) ? promises : [];
704
727
  if (list.length === 0) return Promise.resolve(null);
@@ -1208,6 +1231,7 @@ async function main() {
1208
1231
  }
1209
1232
 
1210
1233
  if (!sessionId || !workspaceDir) return;
1234
+ const sessionIdLower = String(sessionId).toLowerCase();
1211
1235
 
1212
1236
  const { settingsPath, settings } = resolveSessionSettings(workspaceDir, sessionId);
1213
1237
  const modelId =
@@ -1257,14 +1281,34 @@ async function main() {
1257
1281
  } catch {}
1258
1282
  }, 0).unref();
1259
1283
 
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) {
1284
+ let reseedInProgress = false;
1285
+ let reseedQueued = false;
1286
+
1287
+ function updateLastFromContext(ctx, updateOutputTokens) {
1288
+ const cacheRead = Number(ctx?.cacheReadInputTokens);
1289
+ const contextCount = Number(ctx?.contextCount);
1290
+ const out = Number(ctx?.outputTokens);
1291
+ if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
1292
+ if (Number.isFinite(contextCount)) last.contextCount = contextCount;
1293
+ if (updateOutputTokens && Number.isFinite(out)) last.outputTokens = out;
1294
+ }
1295
+
1296
+ function seedLastContextFromLog(options) {
1297
+ const opts = options || {};
1298
+ const maxScanBytes = Number.isFinite(opts.maxScanBytes) ? opts.maxScanBytes : 64 * 1024 * 1024;
1299
+ const preferStreaming = !!opts.preferStreaming;
1300
+
1301
+ if (reseedInProgress) {
1302
+ reseedQueued = true;
1303
+ return;
1304
+ }
1305
+ reseedInProgress = true;
1306
+
1263
1307
  setTimeout(() => {
1264
1308
  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
1309
+ // Backward scan to find the most recent context entry for this session.
1310
+ // Prefer streaming context if requested; otherwise accept any context line
1311
+ // that includes cacheReadInputTokens/contextCount fields.
1268
1312
  const CHUNK_BYTES = 1024 * 1024; // 1 MiB
1269
1313
 
1270
1314
  const fd = fs.openSync(LOG_PATH, 'r');
@@ -1276,7 +1320,7 @@ async function main() {
1276
1320
  let remainder = '';
1277
1321
  let seeded = false;
1278
1322
 
1279
- while (pos > 0 && scanned < MAX_SCAN_BYTES && !seeded) {
1323
+ while (pos > 0 && scanned < maxScanBytes && !seeded) {
1280
1324
  const readSize = Math.min(CHUNK_BYTES, pos);
1281
1325
  const start = pos - readSize;
1282
1326
  const buf = Buffer.alloc(readSize);
@@ -1296,8 +1340,12 @@ async function main() {
1296
1340
  const line = String(lines[i] || '').trimEnd();
1297
1341
  if (!line) continue;
1298
1342
  if (!line.includes('Context:')) continue;
1299
- if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
1300
- if (!line.includes('[Agent] Streaming result')) continue;
1343
+ const sid = extractSessionIdFromLine(line);
1344
+ if (!sid || String(sid).toLowerCase() !== sessionIdLower) continue;
1345
+
1346
+ const isStreaming = line.includes('[Agent] Streaming result');
1347
+ if (preferStreaming && !isStreaming) continue;
1348
+
1301
1349
  const ctxIndex = line.indexOf('Context: ');
1302
1350
  if (ctxIndex === -1) continue;
1303
1351
  const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
@@ -1307,12 +1355,13 @@ async function main() {
1307
1355
  } catch {
1308
1356
  continue;
1309
1357
  }
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;
1358
+
1359
+ const cacheRead = Number(ctx?.cacheReadInputTokens);
1360
+ const contextCount = Number(ctx?.contextCount);
1361
+ const hasUsage = Number.isFinite(cacheRead) || Number.isFinite(contextCount);
1362
+ if (!hasUsage) continue;
1363
+
1364
+ updateLastFromContext(ctx, isStreaming);
1316
1365
  seeded = true;
1317
1366
  break;
1318
1367
  }
@@ -1324,16 +1373,31 @@ async function main() {
1324
1373
  fs.closeSync(fd);
1325
1374
  } catch {}
1326
1375
  }
1327
-
1328
- renderNow();
1329
1376
  } catch {
1330
1377
  // ignore
1378
+ } finally {
1379
+ reseedInProgress = false;
1380
+ if (reseedQueued) {
1381
+ reseedQueued = false;
1382
+ seedLastContextFromLog({ maxScanBytes, preferStreaming });
1383
+ return;
1384
+ }
1385
+ renderNow();
1331
1386
  }
1332
1387
  }, 0).unref();
1333
1388
  }
1334
1389
 
1390
+ // Seed prompt-context usage from existing logs (important for resumed sessions).
1391
+ // Do this asynchronously to avoid delaying the first statusline frame.
1392
+ let initialSeedDone = false;
1393
+ if (resumeFlag || resumeId) {
1394
+ initialSeedDone = true;
1395
+ seedLastContextFromLog({ maxScanBytes: 64 * 1024 * 1024, preferStreaming: true });
1396
+ }
1397
+
1335
1398
  // Watch session settings for autonomy/reasoning changes (cheap polling with mtime).
1336
1399
  let settingsMtimeMs = 0;
1400
+ let lastCtxPollMs = 0;
1337
1401
  setInterval(() => {
1338
1402
  try {
1339
1403
  const stat = fs.statSync(settingsPath);
@@ -1356,6 +1420,17 @@ async function main() {
1356
1420
  }
1357
1421
  }, 750).unref();
1358
1422
 
1423
+ // Fallback: periodically rescan log if context is still zero after startup.
1424
+ // This handles cases where tail misses early log entries.
1425
+ setInterval(() => {
1426
+ const now = Date.now();
1427
+ if (now - START_MS < 3000) return; // wait 3s after startup
1428
+ if (last.contextCount > 0 || last.cacheReadInputTokens > 0) return; // already have data
1429
+ if (now - lastCtxPollMs < 5000) return; // throttle to every 5s
1430
+ lastCtxPollMs = now;
1431
+ seedLastContextFromLog({ maxScanBytes: 4 * 1024 * 1024, preferStreaming: false });
1432
+ }, 2000).unref();
1433
+
1359
1434
  // Follow the Factory log and update based on session-scoped events.
1360
1435
  const tail = spawn('tail', ['-n', '0', '-F', LOG_PATH], {
1361
1436
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -1370,8 +1445,51 @@ async function main() {
1370
1445
  const line = buffer.slice(0, idx).trimEnd();
1371
1446
  buffer = buffer.slice(idx + 1);
1372
1447
 
1373
- if (!line.includes('Context:')) continue;
1374
- if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
1448
+ const lineSessionId = extractSessionIdFromLine(line);
1449
+ const isSessionLine =
1450
+ lineSessionId && String(lineSessionId).toLowerCase() === sessionIdLower;
1451
+
1452
+ let compactionChanged = false;
1453
+ let compactionEnded = false;
1454
+ if (line.includes('[Compaction]')) {
1455
+ // Accept session-scoped compaction lines; allow end markers to clear even
1456
+ // if the line lacks a session id (some builds omit Context on end lines).
1457
+ if (isSessionLine || (compacting && !lineSessionId)) {
1458
+ const next = nextCompactionState(line, compacting);
1459
+ if (next !== compacting) {
1460
+ compacting = next;
1461
+ compactionChanged = true;
1462
+ if (!compacting) compactionEnded = true;
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ if (!line.includes('Context:')) {
1468
+ if (compactionChanged) {
1469
+ lastRenderAt = Date.now();
1470
+ renderNow();
1471
+ }
1472
+ if (compactionEnded) {
1473
+ // Compaction often completes between turns. Refresh ctx numbers promptly
1474
+ // by rescanning the most recent Context entry for this session.
1475
+ setTimeout(() => {
1476
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1477
+ }, 250).unref();
1478
+ }
1479
+ continue;
1480
+ }
1481
+ if (!isSessionLine) {
1482
+ if (compactionChanged) {
1483
+ lastRenderAt = Date.now();
1484
+ renderNow();
1485
+ }
1486
+ if (compactionEnded) {
1487
+ setTimeout(() => {
1488
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1489
+ }, 250).unref();
1490
+ }
1491
+ continue;
1492
+ }
1375
1493
 
1376
1494
  const ctxIndex = line.indexOf('Context: ');
1377
1495
  if (ctxIndex === -1) continue;
@@ -1380,28 +1498,41 @@ async function main() {
1380
1498
  try {
1381
1499
  ctx = JSON.parse(jsonStr);
1382
1500
  } catch {
1501
+ if (compactionChanged) {
1502
+ lastRenderAt = Date.now();
1503
+ renderNow();
1504
+ }
1383
1505
  continue;
1384
1506
  }
1385
1507
 
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;
1508
+ // Context usage can appear on multiple session-scoped log lines; update whenever present.
1509
+ // (Streaming is still the best source for outputTokens / LastOut.)
1510
+ updateLastFromContext(ctx, false);
1511
+
1512
+ // For new sessions: if this is the first valid Context line and ctx is still 0,
1513
+ // trigger a reseed to catch any earlier log entries we might have missed.
1514
+ if (!initialSeedDone && last.contextCount === 0) {
1515
+ initialSeedDone = true;
1516
+ setTimeout(() => {
1517
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1518
+ }, 100).unref();
1394
1519
  }
1395
1520
 
1396
- // Compaction state hint.
1397
- if (line.includes('[Compaction] Start')) compacting = true;
1398
- if (line.includes('[Compaction] End')) compacting = false;
1521
+ if (line.includes('[Agent] Streaming result')) {
1522
+ updateLastFromContext(ctx, true);
1523
+ }
1399
1524
 
1400
1525
  const now = Date.now();
1401
- if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1526
+ if (compactionChanged || now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
1402
1527
  lastRenderAt = now;
1403
1528
  renderNow();
1404
1529
  }
1530
+
1531
+ if (compactionEnded) {
1532
+ setTimeout(() => {
1533
+ seedLastContextFromLog({ maxScanBytes: 8 * 1024 * 1024, preferStreaming: false });
1534
+ }, 250).unref();
1535
+ }
1405
1536
  }
1406
1537
  });
1407
1538
 
@@ -1419,986 +1550,949 @@ main().catch(() => {});
1419
1550
  `;
1420
1551
  }
1421
1552
  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()
1553
+ return generateStatuslineWrapperScriptBun(execTargetPath, monitorScriptPath, sessionsScriptPath);
1554
+ }
1555
+ function generateStatuslineWrapperScriptBun(execTargetPath, monitorScriptPath, sessionsScriptPath) {
1556
+ return `#!/usr/bin/env bun
1557
+ // Droid with Statusline (Bun PTY proxy)
1558
+ // Auto-generated by droid-patch --statusline
1559
+
1560
+ const EXEC_TARGET = ${JSON.stringify(execTargetPath)};
1561
+ const STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)};
1562
+ const SESSIONS_SCRIPT = ${sessionsScriptPath ? JSON.stringify(sessionsScriptPath) : "null"};
1563
+
1564
+ const IS_APPLE_TERMINAL = process.env.TERM_PROGRAM === "Apple_Terminal";
1565
+ const MIN_RENDER_INTERVAL_MS = IS_APPLE_TERMINAL ? 800 : 400;
1566
+ const QUIET_MS = 50;
1567
+ const FORCE_REPAINT_INTERVAL_MS = 2000;
1568
+ const RESERVED_ROWS = 1;
1569
+
1570
+ const BYPASS_FLAGS = new Set(["--help", "-h", "--version", "-V"]);
1571
+ const BYPASS_COMMANDS = new Set(["help", "version", "completion", "completions", "exec"]);
1572
+
1573
+ function shouldPassthrough(argv) {
1574
+ for (const a of argv) {
1575
+ if (a === "--") break;
1576
+ if (BYPASS_FLAGS.has(a)) return true;
1577
+ }
1578
+ let endOpts = false;
1579
+ let cmd = null;
1580
+ for (const a of argv) {
1581
+ if (a === "--") {
1582
+ endOpts = true;
1583
+ continue;
1584
+ }
1585
+ if (!endOpts && a.startsWith("-")) continue;
1586
+ cmd = a;
1587
+ break;
1588
+ }
1589
+ return cmd && BYPASS_COMMANDS.has(cmd);
1590
+ }
1591
+
1592
+ function isSessionsCommand(argv) {
1593
+ for (const a of argv) {
1594
+ if (a === "--") return false;
1595
+ if (a === "--sessions") return true;
1596
+ }
1597
+ return false;
1598
+ }
1599
+
1600
+ async function execPassthrough(argv) {
1601
+ const proc = Bun.spawn([EXEC_TARGET, ...argv], {
1602
+ stdin: "inherit",
1603
+ stdout: "inherit",
1604
+ stderr: "inherit",
1605
+ });
1606
+ const code = await proc.exited;
1607
+ process.exit(code ?? 0);
1608
+ }
1609
+
1610
+ async function runSessions() {
1611
+ if (SESSIONS_SCRIPT) {
1612
+ const proc = Bun.spawn(["node", String(SESSIONS_SCRIPT)], {
1613
+ stdin: "inherit",
1614
+ stdout: "inherit",
1615
+ stderr: "inherit",
1616
+ });
1617
+ const code = await proc.exited;
1618
+ process.exit(code ?? 0);
1619
+ }
1620
+ process.stderr.write("[statusline] sessions script not found\\n");
1621
+ process.exit(1);
1622
+ }
1623
+
1624
+ function writeStdout(s) {
1625
+ try {
1626
+ process.stdout.write(s);
1627
+ } catch {
1628
+ // ignore
1629
+ }
1630
+ }
1631
+
1632
+ function termSize() {
1633
+ const rows = Number(process.stdout.rows || 24);
1634
+ const cols = Number(process.stdout.columns || 80);
1635
+ return { rows: Number.isFinite(rows) ? rows : 24, cols: Number.isFinite(cols) ? cols : 80 };
1636
+ }
1637
+
1638
+ const ANSI_RE = /\\x1b\\[[0-9;]*m/g;
1639
+ const RESET_SGR = "\\x1b[0m";
1640
+
1641
+ function visibleWidth(text) {
1642
+ return String(text || "").replace(ANSI_RE, "").length;
1643
+ }
1644
+
1645
+ function clampAnsi(text, cols) {
1646
+ if (!cols || cols <= 0) return String(text || "");
1647
+ cols = cols > 1 ? cols - 1 : cols; // avoid last-column wrap
1648
+ if (cols < 10) return String(text || "");
1649
+ const s = String(text || "");
1650
+ let visible = 0;
1651
+ let i = 0;
1652
+ const out = [];
1653
+ while (i < s.length) {
1654
+ const ch = s[i];
1655
+ if (ch === "\\x1b") {
1656
+ const m = s.indexOf("m", i);
1657
+ if (m !== -1) {
1658
+ out.push(s.slice(i, m + 1));
1659
+ i = m + 1;
1660
+ continue;
1661
+ }
1662
+ out.push(ch);
1663
+ i += 1;
1664
+ continue;
1665
+ }
1666
+ if (visible >= cols) break;
1667
+ out.push(ch);
1668
+ i += 1;
1669
+ visible += 1;
1670
+ }
1671
+ if (i < s.length && cols >= 1) {
1672
+ if (visible >= cols) {
1673
+ if (out.length) out[out.length - 1] = "…";
1674
+ else out.push("…");
1675
+ } else {
1676
+ out.push("");
1677
+ }
1678
+ out.push(RESET_SGR);
1679
+ }
1680
+ return out.join("");
1681
+ }
1682
+
1683
+ function splitSegments(text) {
1684
+ if (!text) return [];
1685
+ const s = String(text);
1686
+ const segments = [];
1687
+ let start = 0;
1688
+ while (true) {
1689
+ const idx = s.indexOf(RESET_SGR, start);
1690
+ if (idx === -1) {
1691
+ const tail = s.slice(start);
1692
+ if (tail) segments.push(tail);
1693
+ break;
1694
+ }
1695
+ const seg = s.slice(start, idx + RESET_SGR.length);
1696
+ if (seg) segments.push(seg);
1697
+ start = idx + RESET_SGR.length;
1698
+ }
1699
+ return segments;
1700
+ }
1701
+
1702
+ function wrapSegments(segments, cols) {
1703
+ if (!segments || segments.length === 0) return [""];
1704
+ if (!cols || cols <= 0) return [segments.join("")];
1705
+
1706
+ const lines = [];
1707
+ let cur = [];
1708
+ let curW = 0;
1709
+
1710
+ for (let seg of segments) {
1711
+ let segW = visibleWidth(seg);
1712
+ if (segW <= 0) continue;
1713
+
1714
+ if (cur.length === 0) {
1715
+ if (segW > cols) {
1716
+ seg = clampAnsi(seg, cols);
1717
+ segW = visibleWidth(seg);
1718
+ }
1719
+ cur = [seg];
1720
+ curW = segW;
1721
+ continue;
1722
+ }
1723
+
1724
+ if (curW + segW <= cols) {
1725
+ cur.push(seg);
1726
+ curW += segW;
1727
+ } else {
1728
+ lines.push(cur.join(""));
1729
+ if (segW > cols) {
1730
+ seg = clampAnsi(seg, cols);
1731
+ segW = visibleWidth(seg);
1732
+ }
1733
+ cur = [seg];
1734
+ curW = segW;
1735
+ }
1736
+ }
1737
+
1738
+ if (cur.length) lines.push(cur.join(""));
1739
+ return lines.length ? lines : [""];
1740
+ }
1741
+
1742
+ class StatusRenderer {
1743
+ constructor() {
1744
+ this.raw = "";
1745
+ this.segments = [];
1746
+ this.lines = [""];
1747
+ this.activeReservedRows = RESERVED_ROWS;
1748
+ this.force = false;
1749
+ this.urgent = false;
1750
+ this.lastRenderMs = 0;
1751
+ this.lastChildOutMs = 0;
1752
+ this.cursorVisible = true;
1753
+ }
1754
+ noteChildOutput() {
1755
+ this.lastChildOutMs = Date.now();
1756
+ }
1757
+ setCursorVisible(v) {
1758
+ this.cursorVisible = !!v;
1759
+ }
1760
+ forceRepaint(urgent = false) {
1761
+ this.force = true;
1762
+ if (urgent) this.urgent = true;
1763
+ }
1764
+ setActiveReservedRows(n) {
1765
+ const v = Number(n || 1);
1766
+ this.activeReservedRows = Number.isFinite(v) ? Math.max(1, Math.trunc(v)) : 1;
1767
+ }
1768
+ setLine(line) {
1769
+ const next = String(line || "");
1770
+ if (next !== this.raw) {
1771
+ this.raw = next;
1772
+ this.segments = splitSegments(next);
1773
+ this.force = true;
1774
+ }
1775
+ }
1776
+ desiredReservedRows(physicalRows, cols, minReserved) {
1777
+ let rows = Number(physicalRows || 24);
1778
+ rows = Number.isFinite(rows) ? rows : 24;
1779
+ cols = Number(cols || 80);
1780
+ cols = Number.isFinite(cols) ? cols : 80;
1781
+
1782
+ const maxReserved = Math.max(1, rows - 4);
1783
+ const segs = this.segments.length ? this.segments : (this.raw ? [this.raw] : []);
1784
+ let lines = segs.length ? wrapSegments(segs, cols) : [""];
1785
+
1786
+ const needed = Math.min(lines.length, maxReserved);
1787
+ let desired = Math.max(Number(minReserved || 1), needed);
1788
+ desired = Math.min(desired, maxReserved);
1789
+
1790
+ if (lines.length < desired) lines = new Array(desired - lines.length).fill("").concat(lines);
1791
+ if (lines.length > desired) lines = lines.slice(-desired);
1792
+
1793
+ this.lines = lines;
1794
+ return desired;
1795
+ }
1796
+ clearReservedArea(physicalRows, cols, reservedRows, restoreRow = 1, restoreCol = 1) {
1797
+ let rows = Number(physicalRows || 24);
1798
+ rows = Number.isFinite(rows) ? rows : 24;
1799
+ cols = Number(cols || 80);
1800
+ cols = Number.isFinite(cols) ? cols : 80;
1801
+ let reserved = Number(reservedRows || 1);
1802
+ reserved = Number.isFinite(reserved) ? Math.max(1, Math.trunc(reserved)) : 1;
1803
+
1804
+ reserved = Math.min(reserved, rows);
1805
+ const startRow = rows - reserved + 1;
1806
+ const parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR];
1807
+ for (let i = 0; i < reserved; i++) parts.push("\\x1b[" + (startRow + i) + ";1H\\x1b[2K");
1808
+ parts.push("\\x1b[" + restoreRow + ";" + restoreCol + "H");
1809
+ parts.push(this.cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l");
1810
+ parts.push("\\x1b[?2026l");
1811
+ writeStdout(parts.join(""));
1812
+ }
1813
+ render(physicalRows, cols, restoreRow = 1, restoreCol = 1) {
1814
+ if (!this.force) return;
1815
+ if (!this.raw) {
1816
+ this.force = false;
1817
+ this.urgent = false;
1818
+ return;
1819
+ }
1820
+ const now = Date.now();
1821
+ if (!this.urgent && now - this.lastRenderMs < MIN_RENDER_INTERVAL_MS) return;
1822
+ if (!this.urgent && QUIET_MS > 0 && now - this.lastChildOutMs < QUIET_MS) return;
1823
+
1824
+ let rows = Number(physicalRows || 24);
1825
+ rows = Number.isFinite(rows) ? rows : 24;
1826
+ cols = Number(cols || 80);
1827
+ cols = Number.isFinite(cols) ? cols : 80;
1828
+ if (cols <= 0) cols = 80;
1829
+
1830
+ const reserved = Math.max(1, Math.min(this.activeReservedRows, Math.max(1, rows - 4)));
1831
+ const startRow = rows - reserved + 1;
1832
+ const childRows = rows - reserved;
1833
+
1834
+ let lines = this.lines.length ? this.lines.slice() : [""];
1835
+ if (lines.length < reserved) lines = new Array(reserved - lines.length).fill("").concat(lines);
1836
+ if (lines.length > reserved) lines = lines.slice(-reserved);
1837
+
1838
+ const parts = ["\\x1b[?2026h", "\\x1b[?25l"];
1839
+ parts.push("\\x1b[1;" + childRows + "r");
1840
+ for (let i = 0; i < reserved; i++) {
1841
+ const row = startRow + i;
1842
+ const text = clampAnsi(lines[i], cols);
1843
+ parts.push("\\x1b[" + row + ";1H" + RESET_SGR + "\\x1b[2K");
1844
+ parts.push("\\x1b[" + row + ";1H" + text + RESET_SGR);
1845
+ }
1846
+ parts.push("\\x1b[" + restoreRow + ";" + restoreCol + "H");
1847
+ parts.push(this.cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l");
1848
+ parts.push("\\x1b[?2026l");
1849
+ writeStdout(parts.join(""));
1850
+
1851
+ this.lastRenderMs = now;
1852
+ this.force = false;
1853
+ this.urgent = false;
1854
+ }
1855
+ clear() {
1856
+ const { rows, cols } = termSize();
1857
+ this.clearReservedArea(rows, cols, Math.max(this.activeReservedRows, RESERVED_ROWS));
1858
+ }
1859
+ }
1860
+
1861
+ class OutputRewriter {
1862
+ constructor() {
1863
+ this.buf = new Uint8Array(0);
1864
+ }
1865
+ feed(chunk, maxRow) {
1866
+ if (!chunk || chunk.length === 0) return chunk;
1867
+ const merged = new Uint8Array(this.buf.length + chunk.length);
1868
+ merged.set(this.buf, 0);
1869
+ merged.set(chunk, this.buf.length);
1870
+ this.buf = new Uint8Array(0);
1871
+
1872
+ const out = [];
1873
+ let i = 0;
1874
+
1875
+ const isFinal = (v) => v >= 0x40 && v <= 0x7e;
1876
+
1877
+ while (i < merged.length) {
1878
+ const b = merged[i];
1879
+ if (b !== 0x1b) {
1880
+ out.push(b);
1881
+ i += 1;
1882
+ continue;
1883
+ }
1884
+ if (i + 1 >= merged.length) {
1885
+ this.buf = merged.slice(i);
1886
+ break;
1887
+ }
1888
+ const nxt = merged[i + 1];
1889
+ if (nxt !== 0x5b) {
1890
+ out.push(b);
1891
+ i += 1;
1892
+ continue;
1893
+ }
1894
+
1895
+ let j = i + 2;
1896
+ while (j < merged.length && !isFinal(merged[j])) j += 1;
1897
+ if (j >= merged.length) {
1898
+ this.buf = merged.slice(i);
1899
+ break;
1900
+ }
1901
+ const final = merged[j];
1902
+ let seq = merged.slice(i, j + 1);
1903
+
1904
+ if ((final === 0x48 || final === 0x66) && maxRow > 0) {
1905
+ const params = merged.slice(i + 2, j);
1906
+ const s = new TextDecoder().decode(params);
1907
+ if (!s || /^[0-9;]/.test(s)) {
1908
+ const parts = s ? s.split(";") : [];
1909
+ const row = Number(parts[0] || 1);
1910
+ const col = Number(parts[1] || 1);
1911
+ let r = Number.isFinite(row) ? row : 1;
1912
+ let c = Number.isFinite(col) ? col : 1;
1913
+ if (r === 999 || r > maxRow) r = maxRow;
1914
+ if (r < 1) r = 1;
1915
+ if (c < 1) c = 1;
1916
+ const newParams = new TextEncoder().encode(String(r) + ";" + String(c));
1917
+ const ns = new Uint8Array(2 + newParams.length + 1);
1918
+ ns[0] = 0x1b;
1919
+ ns[1] = 0x5b;
1920
+ ns.set(newParams, 2);
1921
+ ns[ns.length - 1] = final;
1922
+ seq = ns;
1923
+ }
1924
+ } else if (final === 0x72 && maxRow > 0) {
1925
+ const params = merged.slice(i + 2, j);
1926
+ const s = new TextDecoder().decode(params);
1927
+ if (!s || /^[0-9;]/.test(s)) {
1928
+ const parts = s ? s.split(";") : [];
1929
+ const top = Number(parts[0] || 1);
1930
+ const bottom = Number(parts[1] || maxRow);
1931
+ let t = Number.isFinite(top) ? top : 1;
1932
+ let btm = Number.isFinite(bottom) ? bottom : maxRow;
1933
+ if (t <= 0) t = 1;
1934
+ if (btm <= 0 || btm === 999 || btm > maxRow) btm = maxRow;
1935
+ if (t > btm) t = 1;
1936
+ const str = "\\x1b[" + String(t) + ";" + String(btm) + "r";
1937
+ seq = new TextEncoder().encode(str);
1938
+ }
1939
+ }
1940
+
1941
+ for (const bb of seq) out.push(bb);
1942
+ i = j + 1;
1943
+ }
1944
+
1945
+ return new Uint8Array(out);
1946
+ }
1947
+ }
1948
+
1949
+ class CursorTracker {
1950
+ constructor() {
1951
+ this.row = 1;
1952
+ this.col = 1;
1953
+ this.savedRow = 1;
1954
+ this.savedCol = 1;
1955
+ this.buf = new Uint8Array(0);
1956
+ this.inOsc = false;
1957
+ this.utf8Cont = 0;
1958
+ this.wrapPending = false;
1959
+ }
1960
+ position() {
1961
+ return { row: this.row, col: this.col };
1962
+ }
1963
+ feed(chunk, maxRow, maxCol) {
1964
+ if (!chunk || chunk.length === 0) return;
1965
+ maxRow = Math.max(1, Number(maxRow || 1));
1966
+ maxCol = Math.max(1, Number(maxCol || 1));
1967
+
1968
+ const merged = new Uint8Array(this.buf.length + chunk.length);
1969
+ merged.set(this.buf, 0);
1970
+ merged.set(chunk, this.buf.length);
1971
+ this.buf = new Uint8Array(0);
1972
+
1973
+ const clamp = () => {
1974
+ if (this.row < 1) this.row = 1;
1975
+ else if (this.row > maxRow) this.row = maxRow;
1976
+ if (this.col < 1) this.col = 1;
1977
+ else if (this.col > maxCol) this.col = maxCol;
1978
+ };
1979
+
1980
+ const parseIntDefault = (v, d) => {
1981
+ const n = Number(v);
1982
+ return Number.isFinite(n) && n > 0 ? Math.trunc(n) : d;
1983
+ };
1984
+
1985
+ let i = 0;
1986
+ const isFinal = (v) => v >= 0x40 && v <= 0x7e;
1987
+
1988
+ while (i < merged.length) {
1989
+ const b = merged[i];
1990
+
1991
+ if (this.inOsc) {
1992
+ if (b === 0x07) {
1993
+ this.inOsc = false;
1994
+ i += 1;
1995
+ continue;
1996
+ }
1997
+ if (b === 0x1b) {
1998
+ if (i + 1 >= merged.length) {
1999
+ this.buf = merged.slice(i);
2000
+ break;
2001
+ }
2002
+ if (merged[i + 1] === 0x5c) {
2003
+ this.inOsc = false;
2004
+ i += 2;
2005
+ continue;
2006
+ }
2007
+ }
2008
+ i += 1;
2009
+ continue;
2010
+ }
2011
+
2012
+ if (this.utf8Cont > 0) {
2013
+ if (b >= 0x80 && b <= 0xbf) {
2014
+ this.utf8Cont -= 1;
2015
+ i += 1;
2016
+ continue;
2017
+ }
2018
+ this.utf8Cont = 0;
2019
+ }
2020
+
2021
+ if (b === 0x1b) {
2022
+ this.wrapPending = false;
2023
+ if (i + 1 >= merged.length) {
2024
+ this.buf = merged.slice(i);
2025
+ break;
2026
+ }
2027
+ const nxt = merged[i + 1];
2028
+
2029
+ if (nxt === 0x5b) {
2030
+ let j = i + 2;
2031
+ while (j < merged.length && !isFinal(merged[j])) j += 1;
2032
+ if (j >= merged.length) {
2033
+ this.buf = merged.slice(i);
2034
+ break;
2035
+ }
2036
+ const final = merged[j];
2037
+ const params = merged.slice(i + 2, j);
2038
+ const s = new TextDecoder().decode(params);
2039
+ if (s && !/^[0-9;]/.test(s)) {
2040
+ i = j + 1;
2041
+ continue;
2042
+ }
2043
+ const parts = s ? s.split(";") : [];
2044
+ const p0 = parseIntDefault(parts[0] || "", 1);
2045
+ const p1 = parseIntDefault(parts[1] || "", 1);
2046
+
2047
+ if (final === 0x48 || final === 0x66) {
2048
+ this.row = p0;
2049
+ this.col = p1;
2050
+ clamp();
2051
+ } else if (final === 0x41) {
2052
+ this.row = Math.max(1, this.row - p0);
2053
+ } else if (final === 0x42) {
2054
+ this.row = Math.min(maxRow, this.row + p0);
2055
+ } else if (final === 0x43) {
2056
+ this.col = Math.min(maxCol, this.col + p0);
2057
+ } else if (final === 0x44) {
2058
+ this.col = Math.max(1, this.col - p0);
2059
+ } else if (final === 0x45) {
2060
+ this.row = Math.min(maxRow, this.row + p0);
2061
+ this.col = 1;
2062
+ } else if (final === 0x46) {
2063
+ this.row = Math.max(1, this.row - p0);
2064
+ this.col = 1;
2065
+ } else if (final === 0x47) {
2066
+ this.col = p0;
2067
+ clamp();
2068
+ } else if (final === 0x64) {
2069
+ this.row = p0;
2070
+ clamp();
2071
+ } else if (final === 0x72) {
2072
+ this.row = 1;
2073
+ this.col = 1;
2074
+ } else if (final === 0x73) {
2075
+ this.savedRow = this.row;
2076
+ this.savedCol = this.col;
2077
+ } else if (final === 0x75) {
2078
+ this.row = this.savedRow;
2079
+ this.col = this.savedCol;
2080
+ clamp();
2081
+ }
2082
+
2083
+ i = j + 1;
2084
+ continue;
2085
+ }
2086
+
2087
+ if (nxt === 0x5d || nxt === 0x50 || nxt === 0x5e || nxt === 0x5f || nxt === 0x58) {
2088
+ this.inOsc = true;
2089
+ i += 2;
2090
+ continue;
2091
+ }
2092
+
2093
+ if (nxt === 0x37) {
2094
+ this.savedRow = this.row;
2095
+ this.savedCol = this.col;
2096
+ i += 2;
2097
+ continue;
2098
+ }
2099
+ if (nxt === 0x38) {
2100
+ this.row = this.savedRow;
2101
+ this.col = this.savedCol;
2102
+ clamp();
2103
+ i += 2;
2104
+ continue;
2105
+ }
2106
+
2107
+ i += 2;
2108
+ continue;
2109
+ }
2110
+
2111
+ if (b === 0x0d) {
2112
+ this.col = 1;
2113
+ this.wrapPending = false;
2114
+ i += 1;
2115
+ continue;
2116
+ }
2117
+ if (b === 0x0a || b === 0x0b || b === 0x0c) {
2118
+ this.row = Math.min(maxRow, this.row + 1);
2119
+ this.wrapPending = false;
2120
+ i += 1;
2121
+ continue;
2122
+ }
2123
+ if (b === 0x08) {
2124
+ this.col = Math.max(1, this.col - 1);
2125
+ this.wrapPending = false;
2126
+ i += 1;
2127
+ continue;
2128
+ }
2129
+ if (b === 0x09) {
2130
+ const nextStop = Math.floor((this.col - 1) / 8 + 1) * 8 + 1;
2131
+ this.col = Math.min(maxCol, nextStop);
2132
+ this.wrapPending = false;
2133
+ i += 1;
2134
+ continue;
2135
+ }
2136
+ if (b < 0x20 || b === 0x7f) {
2137
+ i += 1;
2138
+ continue;
2139
+ }
2140
+
2141
+ if (this.wrapPending) {
2142
+ this.row = Math.min(maxRow, this.row + 1);
2143
+ this.col = 1;
2144
+ this.wrapPending = false;
2145
+ }
2146
+
2147
+ if (b >= 0x80) {
2148
+ if ((b & 0xe0) === 0xc0) this.utf8Cont = 1;
2149
+ else if ((b & 0xf0) === 0xe0) this.utf8Cont = 2;
2150
+ else if ((b & 0xf8) === 0xf0) this.utf8Cont = 3;
2151
+ else this.utf8Cont = 0;
2152
+ }
2153
+
2154
+ if (this.col < maxCol) this.col += 1;
2155
+ else {
2156
+ this.col = maxCol;
2157
+ this.wrapPending = true;
2158
+ }
2159
+ i += 1;
2160
+ }
2161
+ }
2162
+ }
2163
+
2164
+ async function main() {
2165
+ const argv = process.argv.slice(2);
2166
+
2167
+ if (isSessionsCommand(argv)) await runSessions();
2168
+
2169
+ if (!process.stdin.isTTY || !process.stdout.isTTY || shouldPassthrough(argv)) {
2170
+ await execPassthrough(argv);
2171
+ return;
2172
+ }
2173
+
2174
+ // Clean viewport.
2175
+ writeStdout("\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l");
2176
+
2177
+ const renderer = new StatusRenderer();
2178
+ renderer.setLine("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m");
2179
+ renderer.forceRepaint(true);
2180
+
2181
+ let { rows: physicalRows, cols: physicalCols } = termSize();
2182
+ let effectiveReservedRows = renderer.desiredReservedRows(physicalRows, physicalCols, RESERVED_ROWS);
2183
+ renderer.setActiveReservedRows(effectiveReservedRows);
2184
+ let childRows = Math.max(4, physicalRows - effectiveReservedRows);
2185
+ let childCols = Math.max(10, physicalCols);
2186
+
2187
+ // Reserve the bottom rows early, before the child starts writing.
2188
+ writeStdout(
2189
+ "\\x1b[?2026h\\x1b[?25l\\x1b[1;" + childRows + "r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l",
2190
+ );
2191
+ renderer.forceRepaint(true);
2192
+ renderer.render(physicalRows, physicalCols, 1, 1);
2193
+
2194
+ // Spawn child with terminal support.
2195
+ let child;
2196
+ try {
2197
+ child = Bun.spawn([EXEC_TARGET, ...argv], {
2198
+ cwd: process.cwd(),
2199
+ env: process.env,
2200
+ detached: true,
2201
+ terminal: {
2202
+ cols: childCols,
2203
+ rows: childRows,
2204
+ data(_terminal, data) {
2205
+ onChildData(data);
2206
+ },
2207
+ },
2208
+ onExit(_proc, exitCode, signal, _error) {
2209
+ onChildExit(exitCode, signal);
2210
+ },
2211
+ });
2212
+ } catch (e) {
2213
+ process.stderr.write("[statusline] failed to spawn child: " + String(e?.message || e) + "\\n");
2214
+ process.exit(1);
2215
+ }
2216
+
2217
+ const terminal = child.terminal;
2218
+
2219
+ // Best-effort PGID resolution (matches Python wrapper behavior).
2220
+ // This improves session resolution (ps/lsof scanning) and signal forwarding.
2221
+ let pgid = child.pid;
2222
+ try {
2223
+ const res = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(child.pid)], {
2224
+ stdin: "ignore",
2225
+ stdout: "pipe",
2226
+ stderr: "ignore",
2227
+ });
2228
+ if (res && res.exitCode === 0 && res.stdout) {
2229
+ const text = new TextDecoder().decode(res.stdout).trim();
2230
+ const n = Number(text);
2231
+ if (Number.isFinite(n) && n > 0) pgid = Math.trunc(n);
2232
+ }
2233
+ } catch {}
2234
+
2235
+ // Spawn monitor (Node).
2236
+ const monitorEnv = { ...process.env, DROID_STATUSLINE_PGID: String(pgid) };
2237
+ const monitor = Bun.spawn(["node", STATUSLINE_MONITOR, ...argv], {
2238
+ stdin: "ignore",
2239
+ stdout: "pipe",
2240
+ stderr: "ignore",
2241
+ env: monitorEnv,
2242
+ });
2243
+
2244
+ let shouldStop = false;
2245
+ const rewriter = new OutputRewriter();
2246
+ const cursor = new CursorTracker();
2247
+
2248
+ let detectBuf = new Uint8Array(0);
2249
+ let detectStr = "";
2250
+ let cursorVisible = true;
2251
+ let scrollRegionDirty = true;
2252
+ let lastForceRepaintMs = Date.now();
2253
+ let lastPhysicalRows = 0;
2254
+ let lastPhysicalCols = 0;
2255
+
2256
+ function appendDetect(chunk) {
2257
+ const max = 128;
2258
+ const merged = new Uint8Array(Math.min(max, detectBuf.length + chunk.length));
2259
+ const takePrev = Math.max(0, merged.length - chunk.length);
2260
+ if (takePrev > 0) merged.set(detectBuf.slice(Math.max(0, detectBuf.length - takePrev)), 0);
2261
+ merged.set(chunk.slice(Math.max(0, chunk.length - (merged.length - takePrev))), takePrev);
2262
+ detectBuf = merged;
2263
+ try {
2264
+ detectStr = Buffer.from(detectBuf).toString("latin1");
2265
+ } catch {
2266
+ detectStr = "";
2267
+ }
2268
+ }
2269
+
2270
+ function includesBytes(needle) {
2271
+ return detectStr.includes(needle);
2272
+ }
2273
+
2274
+ function lastIndexOfBytes(needle) {
2275
+ return detectStr.lastIndexOf(needle);
2276
+ }
2277
+
2278
+ function includesScrollRegionCSI() {
2279
+ // Equivalent to Python: re.search(b"\\x1b\\\\[[0-9]*;?[0-9]*r", detect_buf)
2280
+ try {
2281
+ return /\\x1b\\[[0-9]*;?[0-9]*r/.test(detectStr);
2282
+ } catch {
2283
+ return false;
2284
+ }
2285
+ }
2286
+
2287
+ function updateCursorVisibility() {
2288
+ const show = includesBytes("\\x1b[?25h");
2289
+ const hide = includesBytes("\\x1b[?25l");
2290
+ if (show || hide) {
2291
+ // best-effort: if both present, whichever appears later "wins"
2292
+ const h = lastIndexOfBytes("\\x1b[?25h");
2293
+ const l = lastIndexOfBytes("\\x1b[?25l");
2294
+ cursorVisible = h > l;
2295
+ renderer.setCursorVisible(cursorVisible);
2296
+ }
2297
+ }
2298
+
2299
+ function needsScrollRegionReset() {
2300
+ return (
2301
+ includesBytes("\\x1b[?1049") ||
2302
+ includesBytes("\\x1b[?1047") ||
2303
+ includesBytes("\\x1b[?47") ||
2304
+ includesBytes("\\x1b[J") ||
2305
+ includesBytes("\\x1b[0J") ||
2306
+ includesBytes("\\x1b[1J") ||
2307
+ includesBytes("\\x1b[2J") ||
2308
+ includesBytes("\\x1b[3J") ||
2309
+ includesBytes("\\x1b[r") ||
2310
+ includesScrollRegionCSI()
2311
+ );
2312
+ }
2313
+
2314
+ function onChildData(data) {
2315
+ if (shouldStop) return;
2316
+ const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
2317
+ appendDetect(chunk);
2318
+ if (needsScrollRegionReset()) scrollRegionDirty = true;
2319
+ updateCursorVisibility();
2320
+
2321
+ renderer.noteChildOutput();
2322
+ const rewritten = rewriter.feed(chunk, childRows);
2323
+ cursor.feed(rewritten, childRows, childCols);
2324
+ writeStdout(Buffer.from(rewritten));
2325
+ }
2326
+
2327
+ function onChildExit(exitCode, signal) {
2328
+ if (shouldStop) return;
2329
+ shouldStop = true;
2330
+ const code = exitCode ?? (signal != null ? 128 + signal : 0);
2331
+ cleanup().finally(() => process.exit(code));
2332
+ }
2333
+
2334
+ async function readMonitor() {
2335
+ if (!monitor.stdout) return;
2336
+ const reader = monitor.stdout.getReader();
2337
+ let buf = "";
2338
+ while (!shouldStop) {
2339
+ const { value, done } = await reader.read();
2340
+ if (done || !value) break;
2341
+ buf += new TextDecoder().decode(value);
2342
+ while (true) {
2343
+ const idx = buf.indexOf("\\n");
2344
+ if (idx === -1) break;
2345
+ const line = buf.slice(0, idx).replace(/\\r$/, "");
2346
+ buf = buf.slice(idx + 1);
2347
+ if (!line) continue;
2348
+ renderer.setLine(line);
2349
+ renderer.forceRepaint(false);
2350
+ }
2351
+ }
2352
+ }
2353
+ readMonitor().catch(() => {});
2354
+
2355
+ function repaintStatusline(forceUrgent = false) {
2356
+ const { row, col } = cursor.position();
2357
+ let r = Math.max(1, Math.min(childRows, row));
2358
+ let c = Math.max(1, Math.min(childCols, col));
2359
+
2360
+ if (scrollRegionDirty) {
2361
+ const seq =
2362
+ "\\x1b[?2026h\\x1b[?25l\\x1b[1;" +
2363
+ childRows +
2364
+ "r\\x1b[" +
2365
+ r +
2366
+ ";" +
2367
+ c +
2368
+ "H" +
2369
+ (cursorVisible ? "\\x1b[?25h" : "\\x1b[?25l") +
2370
+ "\\x1b[?2026l";
2371
+ writeStdout(seq);
2372
+ scrollRegionDirty = false;
2373
+ }
2374
+
2375
+ renderer.forceRepaint(forceUrgent);
2376
+ renderer.render(physicalRows, physicalCols, r, c);
2377
+ }
2378
+
2379
+ function handleSizeChange(nextRows, nextCols, forceUrgent = false) {
2380
+ physicalRows = nextRows;
2381
+ physicalCols = nextCols;
2382
+
2383
+ const desired = renderer.desiredReservedRows(physicalRows, physicalCols, RESERVED_ROWS);
2384
+ const { row, col } = cursor.position();
2385
+ if (desired < effectiveReservedRows) {
2386
+ renderer.clearReservedArea(physicalRows, physicalCols, effectiveReservedRows, row, col);
2387
+ }
2388
+ effectiveReservedRows = desired;
2389
+ renderer.setActiveReservedRows(effectiveReservedRows);
2390
+
2391
+ childRows = Math.max(4, physicalRows - effectiveReservedRows);
2392
+ childCols = Math.max(10, physicalCols);
2393
+ try {
2394
+ terminal.resize(childCols, childRows);
2395
+ } catch {}
2396
+ try {
2397
+ process.kill(-child.pid, "SIGWINCH");
2398
+ } catch {
2399
+ try { process.kill(child.pid, "SIGWINCH"); } catch {}
2400
+ }
2401
+
2402
+ scrollRegionDirty = true;
2403
+ renderer.forceRepaint(true);
2404
+ repaintStatusline(forceUrgent);
2405
+ }
2406
+
2407
+ process.on("SIGWINCH", () => {
2408
+ const next = termSize();
2409
+ handleSizeChange(next.rows, next.cols, true);
2410
+ });
2411
+
2412
+ // Forward signals to child's process group when possible.
2413
+ const forward = (sig) => {
2414
+ try {
2415
+ process.kill(-pgid, sig);
2416
+ } catch {
2417
+ try {
2418
+ process.kill(child.pid, sig);
2419
+ } catch {}
2420
+ }
2421
+ };
2422
+ for (const s of ["SIGTERM", "SIGINT", "SIGHUP"]) {
2423
+ try {
2424
+ process.on(s, () => forward(s));
2425
+ } catch {}
2426
+ }
2427
+
2428
+ // Raw stdin -> PTY.
2429
+ try {
2430
+ process.stdin.setRawMode(true);
2431
+ } catch {}
2432
+ process.stdin.resume();
2433
+ process.stdin.on("data", (buf) => {
2434
+ try {
2435
+ if (typeof buf === "string") terminal.write(buf);
2436
+ else {
2437
+ // Prefer bytes when supported; fall back to UTF-8 decoding.
2438
+ try {
2439
+ // Bun.Terminal.write may accept Uint8Array in newer versions.
2440
+ terminal.write(buf);
2441
+ } catch {
2442
+ terminal.write(new TextDecoder().decode(buf));
2443
+ }
2444
+ }
2445
+ } catch {}
2446
+ });
2447
+
2448
+ const tick = setInterval(() => {
2449
+ if (shouldStop) return;
2450
+ const next = termSize();
2451
+ const sizeChanged = next.rows !== lastPhysicalRows || next.cols !== lastPhysicalCols;
2452
+ const desired = renderer.desiredReservedRows(next.rows, next.cols, RESERVED_ROWS);
2453
+ if (sizeChanged || desired !== effectiveReservedRows) {
2454
+ handleSizeChange(next.rows, next.cols, true);
2455
+ lastPhysicalRows = next.rows;
2456
+ lastPhysicalCols = next.cols;
2457
+ lastForceRepaintMs = Date.now();
2458
+ return;
2459
+ }
2460
+ const now = Date.now();
2461
+ if (now - lastForceRepaintMs >= FORCE_REPAINT_INTERVAL_MS) {
2462
+ repaintStatusline(false);
2463
+ lastForceRepaintMs = now;
2464
+ } else {
2465
+ const { row, col } = cursor.position();
2466
+ renderer.render(physicalRows, physicalCols, row, col);
2467
+ }
2468
+ }, 50);
2469
+
2470
+ async function cleanup() {
2471
+ clearInterval(tick);
2472
+ try {
2473
+ process.stdin.setRawMode(false);
2474
+ } catch {}
2475
+ try {
2476
+ const { row, col } = cursor.position();
2477
+ renderer.clearReservedArea(physicalRows, physicalCols, effectiveReservedRows, row, col);
2478
+ } catch {}
2479
+ try {
2480
+ writeStdout("\\x1b[r\\x1b[0m\\x1b[?25h");
2481
+ } catch {}
2482
+ try {
2483
+ monitor.kill();
2484
+ } catch {}
2485
+ try {
2486
+ terminal.close();
2487
+ } catch {}
2488
+ }
2489
+
2490
+ // Keep process alive until child exits.
2491
+ await child.exited;
2492
+ await cleanup();
2493
+ }
2494
+
2495
+ main().catch(() => process.exit(1));
2402
2496
  `;
2403
2497
  }
2404
2498
  async function createStatuslineFiles(outputDir, execTargetPath, aliasName, sessionsScriptPath) {
@@ -2613,27 +2707,49 @@ async function main() {
2613
2707
  let selected = 0;
2614
2708
  let offset = 0;
2615
2709
 
2710
+ function restoreTerminal() {
2711
+ try { process.stdout.write(SHOW_CURSOR); } catch {}
2712
+ try { process.stdin.setRawMode(false); } catch {}
2713
+ try { process.stdin.pause(); } catch {}
2714
+ }
2715
+
2716
+ function clearScreen() {
2717
+ try { process.stdout.write(CLEAR); } catch {}
2718
+ }
2719
+
2616
2720
  process.stdin.setRawMode(true);
2617
2721
  process.stdin.resume();
2618
2722
  process.stdout.write(HIDE_CURSOR);
2619
2723
 
2620
2724
  render(sessions, selected, offset, rows);
2621
2725
 
2622
- process.stdin.on('data', (key) => {
2726
+ const onKey = (key) => {
2623
2727
  const k = key.toString();
2624
2728
 
2625
2729
  if (k === 'q' || k === '\\x03') { // q or Ctrl+C
2626
- process.stdout.write(SHOW_CURSOR + CLEAR);
2730
+ restoreTerminal();
2731
+ clearScreen();
2627
2732
  process.exit(0);
2628
2733
  }
2629
2734
 
2630
2735
  if (k === '\\r' || k === '\\n') { // Enter
2631
- process.stdout.write(SHOW_CURSOR + CLEAR);
2736
+ // Stop reading input / stop reacting to arrow keys before handing off to droid.
2737
+ process.stdin.off('data', onKey);
2738
+ restoreTerminal();
2739
+ clearScreen();
2632
2740
  const session = sessions[selected];
2633
2741
  console.log(GREEN + 'Resuming session: ' + session.id + RESET);
2634
2742
  console.log(DIM + 'Using: ' + ALIAS_NAME + ' --resume ' + session.id + RESET + '\\n');
2743
+
2744
+ // Avoid the sessions browser reacting to signals while droid is running.
2745
+ try { process.removeAllListeners('SIGINT'); } catch {}
2746
+ try { process.removeAllListeners('SIGTERM'); } catch {}
2747
+ try { process.on('SIGINT', () => {}); } catch {}
2748
+ try { process.on('SIGTERM', () => {}); } catch {}
2749
+
2635
2750
  const child = spawn(ALIAS_NAME, ['--resume', session.id], { stdio: 'inherit' });
2636
2751
  child.on('exit', (code) => process.exit(code || 0));
2752
+ child.on('error', () => process.exit(1));
2637
2753
  return;
2638
2754
  }
2639
2755
 
@@ -2656,10 +2772,13 @@ async function main() {
2656
2772
  }
2657
2773
 
2658
2774
  render(sessions, selected, offset, rows);
2659
- });
2775
+ };
2776
+
2777
+ process.stdin.on('data', onKey);
2660
2778
 
2661
2779
  process.on('SIGINT', () => {
2662
- process.stdout.write(SHOW_CURSOR + CLEAR);
2780
+ restoreTerminal();
2781
+ clearScreen();
2663
2782
  process.exit(0);
2664
2783
  });
2665
2784
  }
@@ -2748,7 +2867,9 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2748
2867
  const backup = options.backup !== false;
2749
2868
  const verbose = options.verbose;
2750
2869
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
2751
- if (!(!!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch) && (websearch || statusline)) {
2870
+ const needsBinaryPatch = !!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch;
2871
+ const statuslineEnabled = statusline;
2872
+ if (!needsBinaryPatch && (websearch || statuslineEnabled)) {
2752
2873
  if (!alias) {
2753
2874
  console.log(styleText("red", "Error: Alias name required for --websearch/--statusline"));
2754
2875
  console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
@@ -2764,14 +2885,14 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2764
2885
  console.log(styleText("white", `Forward target: ${websearchTarget}`));
2765
2886
  if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
2766
2887
  }
2767
- if (statusline) console.log(styleText("white", `Statusline: enabled`));
2888
+ if (statuslineEnabled) console.log(styleText("white", `Statusline: enabled`));
2768
2889
  console.log();
2769
2890
  let execTargetPath = path;
2770
2891
  if (websearch) {
2771
2892
  const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
2772
2893
  execTargetPath = wrapperScript;
2773
2894
  }
2774
- if (statusline) {
2895
+ if (statuslineEnabled) {
2775
2896
  const statuslineDir = join(homedir(), ".droid-patch", "statusline");
2776
2897
  let sessionsScript;
2777
2898
  if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
@@ -2785,7 +2906,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2785
2906
  skipLogin: false,
2786
2907
  apiBase: apiBase || null,
2787
2908
  websearch: !!websearch,
2788
- statusline: !!statusline,
2909
+ statusline: !!statuslineEnabled,
2789
2910
  sessions: !!sessions,
2790
2911
  reasoningEffort: false,
2791
2912
  noTelemetry: false,
@@ -2822,7 +2943,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2822
2943
  }
2823
2944
  return;
2824
2945
  }
2825
- if (!isCustom && !skipLogin && !apiBase && !websearch && !statusline && !reasoningEffort && !noTelemetry) {
2946
+ if (!isCustom && !skipLogin && !apiBase && !websearch && !statuslineEnabled && !reasoningEffort && !noTelemetry) {
2826
2947
  console.log(styleText("yellow", "No patch flags specified. Available patches:"));
2827
2948
  console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
2828
2949
  console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
@@ -2981,7 +3102,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2981
3102
  console.log(styleText("white", ` Forward target: ${websearchTarget}`));
2982
3103
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
2983
3104
  }
2984
- if (statusline) {
3105
+ if (statuslineEnabled) {
2985
3106
  const statuslineDir = join(homedir(), ".droid-patch", "statusline");
2986
3107
  let sessionsScript;
2987
3108
  if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
@@ -2991,7 +3112,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2991
3112
  console.log(styleText("cyan", "Statusline enabled"));
2992
3113
  }
2993
3114
  let aliasResult;
2994
- if (websearch || statusline) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
3115
+ if (websearch || statuslineEnabled) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
2995
3116
  else aliasResult = await createAlias(result.outputPath, alias, verbose);
2996
3117
  const droidVersion = getDroidVersion(path);
2997
3118
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -2999,7 +3120,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2999
3120
  skipLogin: !!skipLogin,
3000
3121
  apiBase: apiBase || null,
3001
3122
  websearch: !!websearch,
3002
- statusline: !!statusline,
3123
+ statusline: !!statuslineEnabled,
3003
3124
  sessions: !!sessions,
3004
3125
  reasoningEffort: !!reasoningEffort,
3005
3126
  noTelemetry: !!noTelemetry,