droid-patch 0.7.1 → 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
 
@@ -1418,976 +1549,959 @@ async function main() {
1418
1549
  main().catch(() => {});
1419
1550
  `;
1420
1551
  }
1421
- function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath) {
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
-
1448
- IS_APPLE_TERMINAL = os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
1449
- MIN_RENDER_INTERVAL_MS = 800 if IS_APPLE_TERMINAL else 400
1450
- QUIET_MS = 50 # Reduced to prevent statusline disappearing
1451
- FORCE_REPAINT_INTERVAL_MS = 2000 # Force repaint every 2 seconds
1452
- RESERVED_ROWS = 1
1453
-
1454
- BYPASS_FLAGS = {"--help", "-h", "--version", "-V"}
1455
- BYPASS_COMMANDS = {"help", "version", "completion", "completions", "exec"}
1456
-
1457
- def _should_passthrough(argv):
1458
- # Any help/version flags before "--"
1459
- for a in argv:
1460
- if a == "--":
1461
- break
1462
- if a in BYPASS_FLAGS:
1463
- return True
1464
-
1465
- # Top-level command token
1466
- end_opts = False
1467
- cmd = None
1468
- for a in argv:
1469
- if a == "--":
1470
- end_opts = True
1471
- continue
1472
- if (not end_opts) and a.startswith("-"):
1473
- continue
1474
- cmd = a
1475
- break
1476
-
1477
- return cmd in BYPASS_COMMANDS
1478
-
1479
- def _exec_passthrough():
1480
- try:
1481
- os.execvp(EXEC_TARGET, [EXEC_TARGET] + sys.argv[1:])
1482
- except Exception as e:
1483
- sys.stderr.write(f"[statusline] passthrough failed: {e}\\n")
1484
- sys.exit(1)
1485
-
1486
- # Passthrough for non-interactive/meta commands (avoid clearing screen / PTY proxy)
1487
- if (not sys.stdin.isatty()) or (not sys.stdout.isatty()) or _should_passthrough(sys.argv[1:]):
1488
- _exec_passthrough()
1489
-
1490
- ANSI_RE = re.compile(r"\\x1b\\[[0-9;]*m")
1491
- RESET_SGR = "\\x1b[0m"
1492
-
1493
- def _term_size():
1494
- try:
1495
- sz = os.get_terminal_size(sys.stdout.fileno())
1496
- return int(sz.lines), int(sz.columns)
1497
- except Exception:
1498
- return 24, 80
1499
-
1500
- def _set_winsize(fd: int, rows: int, cols: int) -> None:
1501
- try:
1502
- winsz = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
1503
- fcntl.ioctl(fd, termios.TIOCSWINSZ, winsz)
1504
- except Exception:
1505
- pass
1506
-
1507
- def _visible_width(text: str) -> int:
1508
- # Remove only SGR sequences; good enough for our generated segments.
1509
- stripped = ANSI_RE.sub("", text)
1510
- return len(stripped)
1511
-
1512
- def _clamp_ansi(text: str, cols: int) -> str:
1513
- if cols <= 0:
1514
- return text
1515
- # Avoid writing into the last column. Some terminals keep an implicit wrap-pending state
1516
- # when the last column is filled, and the next printable character can trigger a scroll.
1517
- cols = cols - 1 if cols > 1 else cols
1518
- if cols < 10:
1519
- return text
1520
- visible = 0
1521
- i = 0
1522
- out = []
1523
- while i < len(text):
1524
- ch = text[i]
1525
- if ch == "\\x1b":
1526
- m = text.find("m", i)
1527
- if m != -1:
1528
- out.append(text[i : m + 1])
1529
- i = m + 1
1530
- continue
1531
- out.append(ch)
1532
- i += 1
1533
- continue
1534
- if visible >= cols:
1535
- break
1536
- out.append(ch)
1537
- i += 1
1538
- visible += 1
1539
- if i < len(text) and cols >= 1:
1540
- if visible >= cols:
1541
- if out:
1542
- out[-1] = "…"
1543
- else:
1544
- out.append("…")
1545
- else:
1546
- out.append("…")
1547
- out.append("\\x1b[0m")
1548
- return "".join(out)
1549
-
1550
- def _split_segments(text: str):
1551
- if not text:
1552
- return []
1553
- segments = []
1554
- start = 0
1555
- while True:
1556
- idx = text.find(RESET_SGR, start)
1557
- if idx == -1:
1558
- tail = text[start:]
1559
- if tail:
1560
- segments.append(tail)
1561
- break
1562
- seg = text[start : idx + len(RESET_SGR)]
1563
- if seg:
1564
- segments.append(seg)
1565
- start = idx + len(RESET_SGR)
1566
- return segments
1567
-
1568
- def _wrap_segments(segments, cols: int):
1569
- if not segments:
1570
- return [""]
1571
- if cols <= 0:
1572
- return ["".join(segments)]
1573
-
1574
- lines = []
1575
- cur = []
1576
- cur_w = 0
1577
-
1578
- for seg in segments:
1579
- seg_w = _visible_width(seg)
1580
- if seg_w <= 0:
1581
- continue
1582
-
1583
- if not cur:
1584
- if seg_w > cols:
1585
- seg = _clamp_ansi(seg, cols)
1586
- seg_w = _visible_width(seg)
1587
- cur = [seg]
1588
- cur_w = seg_w
1589
- continue
1590
-
1591
- if cur_w + seg_w <= cols:
1592
- cur.append(seg)
1593
- cur_w += seg_w
1594
- else:
1595
- lines.append("".join(cur))
1596
- if seg_w > cols:
1597
- seg = _clamp_ansi(seg, cols)
1598
- seg_w = _visible_width(seg)
1599
- cur = [seg]
1600
- cur_w = seg_w
1601
-
1602
- if cur:
1603
- lines.append("".join(cur))
1604
-
1605
- return lines if lines else [""]
1606
-
1607
- class StatusRenderer:
1608
- def __init__(self):
1609
- self._raw = ""
1610
- self._segments = []
1611
- self._lines = [""]
1612
- self._active_reserved_rows = RESERVED_ROWS
1613
- self._last_render_ms = 0
1614
- self._last_child_out_ms = 0
1615
- self._force_repaint = False
1616
- self._urgent = False
1617
- self._cursor_visible = True
1618
-
1619
- def note_child_output(self):
1620
- self._last_child_out_ms = int(time.time() * 1000)
1621
-
1622
- def set_cursor_visible(self, visible: bool):
1623
- self._cursor_visible = bool(visible)
1624
-
1625
- def force_repaint(self, urgent: bool = False):
1626
- self._force_repaint = True
1627
- if urgent:
1628
- self._urgent = True
1629
-
1630
- def set_active_reserved_rows(self, reserved_rows: int):
1631
- try:
1632
- self._active_reserved_rows = max(1, int(reserved_rows or 1))
1633
- except Exception:
1634
- self._active_reserved_rows = 1
1635
-
1636
- def set_line(self, line: str):
1637
- if line != self._raw:
1638
- self._raw = line
1639
- self._segments = _split_segments(line)
1640
- self._force_repaint = True
1641
-
1642
- def _write(self, b: bytes) -> None:
1643
- try:
1644
- os.write(sys.stdout.fileno(), b)
1645
- except Exception:
1646
- pass
1647
-
1648
- def desired_reserved_rows(self, physical_rows: int, cols: int, min_reserved: int):
1649
- try:
1650
- rows = int(physical_rows or 24)
1651
- except Exception:
1652
- rows = 24
1653
- try:
1654
- cols = int(cols or 80)
1655
- except Exception:
1656
- cols = 80
1657
-
1658
- max_reserved = max(1, rows - 4)
1659
- segments = self._segments if self._segments else ([self._raw] if self._raw else [])
1660
- lines = _wrap_segments(segments, cols) if segments else [""]
1661
-
1662
- needed = min(len(lines), max_reserved)
1663
- desired = max(int(min_reserved or 1), needed)
1664
- desired = min(desired, max_reserved)
1665
-
1666
- if len(lines) < desired:
1667
- lines = [""] * (desired - len(lines)) + lines
1668
- if len(lines) > desired:
1669
- lines = lines[-desired:]
1670
-
1671
- self._lines = lines
1672
- return desired
1673
-
1674
- def clear_reserved_area(
1675
- self,
1676
- physical_rows: int,
1677
- cols: int,
1678
- reserved_rows: int,
1679
- restore_row: int = 1,
1680
- restore_col: int = 1,
1681
- ):
1682
- try:
1683
- rows = int(physical_rows or 24)
1684
- except Exception:
1685
- rows = 24
1686
- try:
1687
- cols = int(cols or 80)
1688
- except Exception:
1689
- cols = 80
1690
- try:
1691
- reserved = max(1, int(reserved_rows or 1))
1692
- except Exception:
1693
- reserved = 1
1694
-
1695
- reserved = min(reserved, rows)
1696
- start_row = rows - reserved + 1
1697
- parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR]
1698
- for i in range(reserved):
1699
- parts.append(f"\\x1b[{start_row + i};1H\\x1b[2K")
1700
- parts.append(f"\\x1b[{restore_row};{restore_col}H")
1701
- parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
1702
- parts.append("\\x1b[?2026l")
1703
- self._write("".join(parts).encode("utf-8", "ignore"))
1704
-
1705
- def render(self, physical_rows: int, cols: int, restore_row: int = 1, restore_col: int = 1) -> None:
1706
- now_ms = int(time.time() * 1000)
1707
- if not self._force_repaint:
1708
- return
1709
- if not self._raw:
1710
- self._force_repaint = False
1711
- self._urgent = False
1712
- return
1713
- if (not self._urgent) and (now_ms - self._last_render_ms < MIN_RENDER_INTERVAL_MS):
1714
- return
1715
- # Avoid repainting while child is actively writing (reduces flicker on macOS Terminal).
1716
- if (not self._urgent) and (QUIET_MS > 0 and now_ms - self._last_child_out_ms < QUIET_MS):
1717
- return
1718
-
1719
- try:
1720
- rows = int(physical_rows or 24)
1721
- except Exception:
1722
- rows = 24
1723
- try:
1724
- cols = int(cols or 80)
1725
- except Exception:
1726
- cols = 80
1727
-
1728
- if cols <= 0:
1729
- cols = 80
1730
-
1731
- reserved = max(1, min(self._active_reserved_rows, max(1, rows - 4)))
1732
- start_row = rows - reserved + 1
1733
-
1734
- lines = self._lines or [""]
1735
- if len(lines) < reserved:
1736
- lines = [""] * (reserved - len(lines)) + lines
1737
- if len(lines) > reserved:
1738
- lines = lines[-reserved:]
1739
-
1740
- child_rows = rows - reserved
1741
-
1742
- parts = ["\\x1b[?2026h", "\\x1b[?25l"]
1743
- # Always set scroll region to exclude statusline area
1744
- parts.append(f"\\x1b[1;{child_rows}r")
1745
- for i in range(reserved):
1746
- row = start_row + i
1747
- text = _clamp_ansi(lines[i], cols)
1748
- parts.append(f"\\x1b[{row};1H{RESET_SGR}\\x1b[2K")
1749
- parts.append(f"\\x1b[{row};1H{text}{RESET_SGR}")
1750
- parts.append(f"\\x1b[{restore_row};{restore_col}H")
1751
- parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
1752
- parts.append("\\x1b[?2026l")
1753
-
1754
- self._write("".join(parts).encode("utf-8", "ignore"))
1755
- self._last_render_ms = now_ms
1756
- self._force_repaint = False
1757
- self._urgent = False
1758
-
1759
- def clear(self):
1760
- r, c = _term_size()
1761
- self.clear_reserved_area(r, c, max(self._active_reserved_rows, RESERVED_ROWS))
1762
-
1763
-
1764
- class OutputRewriter:
1765
- # Rewrite a small subset of ANSI cursor positioning commands to ensure the child UI never
1766
- # draws into the reserved statusline rows.
1767
- #
1768
- # Key idea: many TUIs use "ESC[999;1H" to jump to the terminal bottom. If we forward that
1769
- # unmodified, it targets the *physical* bottom row, overwriting our statusline. We clamp it
1770
- # to "max_row" (physical_rows - reserved_rows) so the child's "bottom" becomes the line
1771
- # just above the statusline.
1772
- def __init__(self):
1773
- self._buf = b""
1774
-
1775
- def feed(self, chunk: bytes, max_row: int) -> bytes:
1776
- if not chunk:
1777
- return b""
1778
-
1779
- data = self._buf + chunk
1780
- self._buf = b""
1781
- out = bytearray()
1782
- n = len(data)
1783
- i = 0
1784
-
1785
- def _is_final_byte(v: int) -> bool:
1786
- return 0x40 <= v <= 0x7E
1787
-
1788
- while i < n:
1789
- b = data[i]
1790
- if b != 0x1B: # ESC
1791
- out.append(b)
1792
- i += 1
1793
- continue
1794
-
1795
- if i + 1 >= n:
1796
- self._buf = data[i:]
1797
- break
1798
-
1799
- nxt = data[i + 1]
1800
- if nxt != 0x5B: # not CSI
1801
- out.append(b)
1802
- i += 1
1803
- continue
1804
-
1805
- # CSI sequence: ESC [ ... <final>
1806
- j = i + 2
1807
- while j < n and not _is_final_byte(data[j]):
1808
- j += 1
1809
- if j >= n:
1810
- self._buf = data[i:]
1811
- break
1812
-
1813
- final = data[j]
1814
- seq = data[i : j + 1]
1815
-
1816
- if final in (ord("H"), ord("f")) and max_row > 0:
1817
- params = data[i + 2 : j]
1818
- try:
1819
- s = params.decode("ascii", "ignore")
1820
- except Exception:
1821
- s = ""
1822
-
1823
- # Only handle the simple numeric form (no private/DEC prefixes like "?")
1824
- if not s or s[0] in "0123456789;":
1825
- parts = s.split(";") if s else []
1826
- try:
1827
- row = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
1828
- except Exception:
1829
- row = 1
1830
- try:
1831
- col = int(parts[1]) if (len(parts) >= 2 and parts[1]) else 1
1832
- except Exception:
1833
- col = 1
1834
-
1835
- if row == 999 or row > max_row:
1836
- row = max_row
1837
- if row < 1:
1838
- row = 1
1839
- if col < 1:
1840
- col = 1
1841
-
1842
- new_params = f"{row};{col}".encode("ascii", "ignore")
1843
- seq = b"\\x1b[" + new_params + bytes([final])
1844
-
1845
- elif final == ord("r") and max_row > 0:
1846
- # DECSTBM - Set scrolling region. If the child resets to the full physical screen
1847
- # (e.g. ESC[r), the reserved statusline row becomes scrollable and our statusline
1848
- # will "float" upward when the UI scrolls. Clamp bottom to max_row (child area).
1849
- params = data[i + 2 : j]
1850
- try:
1851
- s = params.decode("ascii", "ignore")
1852
- except Exception:
1853
- s = ""
1854
-
1855
- # Only handle the simple numeric form (no private/DEC prefixes like "?")
1856
- if not s or s[0] in "0123456789;":
1857
- parts = s.split(";") if s else []
1858
- try:
1859
- top = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
1860
- except Exception:
1861
- top = 1
1862
- try:
1863
- bottom = int(parts[1]) if (len(parts) >= 2 and parts[1]) else max_row
1864
- except Exception:
1865
- bottom = max_row
1866
-
1867
- if top <= 0:
1868
- top = 1
1869
- if bottom <= 0 or bottom == 999 or bottom > max_row:
1870
- bottom = max_row
1871
- if top > bottom:
1872
- top = 1
1873
-
1874
- seq = f"\\x1b[{top};{bottom}r".encode("ascii", "ignore")
1875
-
1876
- out.extend(seq)
1877
- i = j + 1
1878
-
1879
- return bytes(out)
1880
-
1881
-
1882
- class CursorTracker:
1883
- # Best-effort cursor tracking so the wrapper can restore the cursor position without using
1884
- # ESC7/ESC8 (which droid/Ink also uses internally).
1885
- def __init__(self):
1886
- self.row = 1
1887
- self.col = 1
1888
- self._saved_row = 1
1889
- self._saved_col = 1
1890
- self._buf = b""
1891
- self._in_osc = False
1892
- self._utf8_cont = 0
1893
- self._wrap_pending = False
1894
-
1895
- def position(self):
1896
- return self.row, self.col
1897
-
1898
- def feed(self, chunk: bytes, max_row: int, max_col: int) -> None:
1899
- if not chunk:
1900
- return
1901
- try:
1902
- max_row = max(1, int(max_row or 1))
1903
- except Exception:
1904
- max_row = 1
1905
- try:
1906
- max_col = max(1, int(max_col or 1))
1907
- except Exception:
1908
- max_col = 1
1909
-
1910
- data = self._buf + chunk
1911
- self._buf = b""
1912
- n = len(data)
1913
- i = 0
1914
-
1915
- def _clamp():
1916
- if self.row < 1:
1917
- self.row = 1
1918
- elif self.row > max_row:
1919
- self.row = max_row
1920
- if self.col < 1:
1921
- self.col = 1
1922
- elif self.col > max_col:
1923
- self.col = max_col
1924
-
1925
- def _parse_int(v: str, default: int) -> int:
1926
- try:
1927
- return int(v) if v else default
1928
- except Exception:
1929
- return default
1930
-
1931
- while i < n:
1932
- b = data[i]
1933
-
1934
- if self._in_osc:
1935
- # OSC/DCS/etc are terminated by BEL or ST (ESC \\).
1936
- if b == 0x07:
1937
- self._in_osc = False
1938
- i += 1
1939
- continue
1940
- if b == 0x1B:
1941
- if i + 1 >= n:
1942
- self._buf = data[i:]
1943
- break
1944
- if data[i + 1] == 0x5C:
1945
- self._in_osc = False
1946
- i += 2
1947
- continue
1948
- i += 1
1949
- continue
1950
-
1951
- if self._utf8_cont > 0:
1952
- if 0x80 <= b <= 0xBF:
1953
- self._utf8_cont -= 1
1954
- i += 1
1955
- continue
1956
- self._utf8_cont = 0
1957
-
1958
- if b == 0x1B: # ESC
1959
- self._wrap_pending = False
1960
- if i + 1 >= n:
1961
- self._buf = data[i:]
1962
- break
1963
- nxt = data[i + 1]
1964
-
1965
- if nxt == 0x5B: # CSI
1966
- j = i + 2
1967
- while j < n and not (0x40 <= data[j] <= 0x7E):
1968
- j += 1
1969
- if j >= n:
1970
- self._buf = data[i:]
1971
- break
1972
- final = data[j]
1973
- params = data[i + 2 : j]
1974
- try:
1975
- s = params.decode("ascii", "ignore")
1976
- except Exception:
1977
- s = ""
1978
-
1979
- if s and s[0] not in "0123456789;":
1980
- i = j + 1
1981
- continue
1982
-
1983
- parts = s.split(";") if s else []
1984
- p0 = _parse_int(parts[0] if len(parts) >= 1 else "", 1)
1985
- p1 = _parse_int(parts[1] if len(parts) >= 2 else "", 1)
1986
-
1987
- if final in (ord("H"), ord("f")):
1988
- self.row = p0
1989
- self.col = p1
1990
- _clamp()
1991
- elif final == ord("A"):
1992
- self.row = max(1, self.row - p0)
1993
- elif final == ord("B"):
1994
- self.row = min(max_row, self.row + p0)
1995
- elif final == ord("C"):
1996
- self.col = min(max_col, self.col + p0)
1997
- elif final == ord("D"):
1998
- self.col = max(1, self.col - p0)
1999
- elif final == ord("E"):
2000
- self.row = min(max_row, self.row + p0)
2001
- self.col = 1
2002
- elif final == ord("F"):
2003
- self.row = max(1, self.row - p0)
2004
- self.col = 1
2005
- elif final == ord("G"):
2006
- self.col = p0
2007
- _clamp()
2008
- elif final == ord("d"):
2009
- self.row = p0
2010
- _clamp()
2011
- elif final == ord("r"):
2012
- # DECSTBM moves the cursor to the home position.
2013
- self.row = 1
2014
- self.col = 1
2015
- elif final == ord("s"):
2016
- self._saved_row = self.row
2017
- self._saved_col = self.col
2018
- elif final == ord("u"):
2019
- self.row = self._saved_row
2020
- self.col = self._saved_col
2021
- _clamp()
2022
-
2023
- i = j + 1
2024
- continue
2025
-
2026
- # OSC, DCS, PM, APC, SOS (terminated by ST or BEL).
2027
- if nxt == 0x5D or nxt in (0x50, 0x5E, 0x5F, 0x58):
2028
- self._in_osc = True
2029
- i += 2
2030
- continue
2031
-
2032
- # DECSC / DECRC
2033
- if nxt == 0x37:
2034
- self._saved_row = self.row
2035
- self._saved_col = self.col
2036
- i += 2
2037
- continue
2038
- if nxt == 0x38:
2039
- self.row = self._saved_row
2040
- self.col = self._saved_col
2041
- _clamp()
2042
- i += 2
2043
- continue
2044
-
2045
- # Other single-escape sequences (ignore).
2046
- i += 2
2047
- continue
2048
-
2049
- if b == 0x0D: # CR
2050
- self.col = 1
2051
- self._wrap_pending = False
2052
- i += 1
2053
- continue
2054
- if b in (0x0A, 0x0B, 0x0C): # LF/VT/FF
2055
- self.row = min(max_row, self.row + 1)
2056
- self._wrap_pending = False
2057
- i += 1
2058
- continue
2059
- if b == 0x08: # BS
2060
- self.col = max(1, self.col - 1)
2061
- self._wrap_pending = False
2062
- i += 1
2063
- continue
2064
- if b == 0x09: # TAB
2065
- next_stop = ((self.col - 1) // 8 + 1) * 8 + 1
2066
- self.col = min(max_col, next_stop)
2067
- self._wrap_pending = False
2068
- i += 1
2069
- continue
2070
-
2071
- if b < 0x20 or b == 0x7F:
2072
- i += 1
2073
- continue
2074
-
2075
- # Printable characters.
2076
- if self._wrap_pending:
2077
- self.row = min(max_row, self.row + 1)
2078
- self.col = 1
2079
- self._wrap_pending = False
2080
-
2081
- if b >= 0x80:
2082
- if (b & 0xE0) == 0xC0:
2083
- self._utf8_cont = 1
2084
- elif (b & 0xF0) == 0xE0:
2085
- self._utf8_cont = 2
2086
- elif (b & 0xF8) == 0xF0:
2087
- self._utf8_cont = 3
2088
- else:
2089
- self._utf8_cont = 0
2090
-
2091
- if self.col < max_col:
2092
- self.col += 1
2093
- else:
2094
- self.col = max_col
2095
- self._wrap_pending = True
2096
-
2097
- i += 1
2098
-
2099
-
2100
- def main():
2101
- # Start from a clean viewport. Droid's TUI assumes a fresh screen; without this,
2102
- # it can visually mix with prior shell output (especially when scrollback exists).
2103
- try:
2104
- os.write(sys.stdout.fileno(), b"\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l")
2105
- except Exception:
2106
- pass
2107
-
2108
- renderer = StatusRenderer()
2109
- renderer.set_line("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m")
2110
- renderer.force_repaint(True)
2111
-
2112
- physical_rows, physical_cols = _term_size()
2113
- effective_reserved_rows = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
2114
- renderer.set_active_reserved_rows(effective_reserved_rows)
2115
-
2116
- child_rows = max(4, physical_rows - effective_reserved_rows)
2117
- child_cols = max(10, physical_cols)
2118
-
2119
- # Reserve the bottom rows up-front, before the child starts writing.
2120
- try:
2121
- seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l"
2122
- os.write(sys.stdout.fileno(), seq.encode("utf-8", "ignore"))
2123
- except Exception:
2124
- pass
2125
- renderer.force_repaint(True)
2126
- renderer.render(physical_rows, physical_cols)
2127
-
2128
- master_fd, slave_fd = pty.openpty()
2129
- _set_winsize(slave_fd, child_rows, child_cols)
2130
-
2131
- child = subprocess.Popen(
2132
- [EXEC_TARGET] + sys.argv[1:],
2133
- stdin=slave_fd,
2134
- stdout=slave_fd,
2135
- stderr=slave_fd,
2136
- close_fds=True,
2137
- start_new_session=True,
2138
- )
2139
- os.close(slave_fd)
2140
-
2141
- rewriter = OutputRewriter()
2142
- cursor = CursorTracker()
2143
-
2144
- monitor = None
2145
- try:
2146
- monitor_env = os.environ.copy()
2147
- try:
2148
- monitor_env["DROID_STATUSLINE_PGID"] = str(os.getpgid(child.pid))
2149
- except Exception:
2150
- monitor_env["DROID_STATUSLINE_PGID"] = str(child.pid)
2151
- monitor = subprocess.Popen(
2152
- ["node", STATUSLINE_MONITOR] + sys.argv[1:],
2153
- stdin=subprocess.DEVNULL,
2154
- stdout=subprocess.PIPE,
2155
- stderr=subprocess.DEVNULL,
2156
- close_fds=True,
2157
- bufsize=0,
2158
- env=monitor_env,
2159
- )
2160
- except Exception:
2161
- monitor = None
2162
-
2163
- monitor_fd = monitor.stdout.fileno() if (monitor and monitor.stdout) else None
2164
-
2165
- def forward(sig, _frame):
2166
- try:
2167
- os.killpg(child.pid, sig)
2168
- except Exception:
2169
- pass
2170
-
2171
- for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
2172
- try:
2173
- signal.signal(s, forward)
2174
- except Exception:
2175
- pass
2176
-
2177
- stdin_fd = sys.stdin.fileno()
2178
- stdout_fd = sys.stdout.fileno()
2179
- old_tty = termios.tcgetattr(stdin_fd)
2180
- try:
2181
- tty.setraw(stdin_fd)
2182
- # Ensure stdout is blocking (prevents sporadic EAGAIN/BlockingIOError on some terminals).
2183
- try:
2184
- os.set_blocking(stdout_fd, True)
2185
- except Exception:
2186
- pass
2187
- os.set_blocking(stdin_fd, False)
2188
- os.set_blocking(master_fd, False)
2189
- if monitor_fd is not None:
2190
- os.set_blocking(monitor_fd, False)
2191
-
2192
- monitor_buf = b""
2193
- detect_buf = b""
2194
- cursor_visible = True
2195
- last_physical_rows = 0
2196
- last_physical_cols = 0
2197
- scroll_region_dirty = True
2198
- last_force_repaint_ms = int(time.time() * 1000)
2199
-
2200
- while True:
2201
- if child.poll() is not None:
2202
- break
2203
-
2204
- read_fds = [master_fd, stdin_fd]
2205
- if monitor_fd is not None:
2206
- read_fds.append(monitor_fd)
2207
-
2208
- try:
2209
- rlist, _, _ = select.select(read_fds, [], [], 0.05)
2210
- except InterruptedError:
2211
- rlist = []
2212
-
2213
- pty_eof = False
2214
- for fd in rlist:
2215
- if fd == stdin_fd:
2216
- try:
2217
- data = os.read(stdin_fd, 4096)
2218
- if data:
2219
- os.write(master_fd, data)
2220
- except BlockingIOError:
2221
- pass
2222
- except OSError:
2223
- pass
2224
- elif fd == master_fd:
2225
- try:
2226
- data = os.read(master_fd, 65536)
2227
- except BlockingIOError:
2228
- data = b""
2229
- except OSError:
2230
- data = b""
2231
-
2232
- if data:
2233
- detect_buf = (detect_buf + data)[-128:]
2234
- # Detect sequences that may affect scroll region or clear screen
2235
- needs_scroll_region_reset = (
2236
- (b"\\x1b[?1049" in detect_buf) # Alt screen
2237
- or (b"\\x1b[?1047" in detect_buf) # Alt screen
2238
- or (b"\\x1b[?47" in detect_buf) # Alt screen
2239
- or (b"\\x1b[J" in detect_buf) # Clear below
2240
- or (b"\\x1b[0J" in detect_buf) # Clear below
2241
- or (b"\\x1b[1J" in detect_buf) # Clear above
2242
- or (b"\\x1b[2J" in detect_buf) # Clear all
2243
- or (b"\\x1b[3J" in detect_buf) # Clear scrollback
2244
- or (b"\\x1b[r" in detect_buf) # Reset scroll region (bare ESC[r)
2245
- )
2246
- # Also detect scroll region changes with parameters (DECSTBM pattern ESC[n;mr)
2247
- if b"\\x1b[" in detect_buf and b"r" in detect_buf:
2248
- if re.search(b"\\x1b\\\\[[0-9]*;?[0-9]*r", detect_buf):
2249
- needs_scroll_region_reset = True
2250
- if needs_scroll_region_reset:
2251
- renderer.force_repaint(True)
2252
- scroll_region_dirty = True
2253
- h = detect_buf.rfind(b"\\x1b[?25h")
2254
- l = detect_buf.rfind(b"\\x1b[?25l")
2255
- if h != -1 or l != -1:
2256
- cursor_visible = h > l
2257
- renderer.set_cursor_visible(cursor_visible)
2258
- renderer.note_child_output()
2259
- data = rewriter.feed(data, child_rows)
2260
- cursor.feed(data, child_rows, child_cols)
2261
- try:
2262
- os.write(stdout_fd, data)
2263
- except BlockingIOError:
2264
- # If stdout is non-blocking for some reason, retry briefly.
2265
- try:
2266
- time.sleep(0.01)
2267
- os.write(stdout_fd, data)
2268
- except Exception:
2269
- pass
2270
- except OSError:
2271
- pass
2272
- else:
2273
- pty_eof = True
2274
- elif monitor_fd is not None and fd == monitor_fd:
2275
- try:
2276
- chunk = os.read(monitor_fd, 65536)
2277
- except BlockingIOError:
2278
- chunk = b""
2279
- except OSError:
2280
- chunk = b""
2281
-
2282
- if chunk:
2283
- monitor_buf += chunk
2284
- while True:
2285
- nl = monitor_buf.find(b"\\n")
2286
- if nl == -1:
2287
- break
2288
- raw = monitor_buf[:nl].rstrip(b"\\r")
2289
- monitor_buf = monitor_buf[nl + 1 :]
2290
- if not raw:
2291
- continue
2292
- renderer.set_line(raw.decode("utf-8", "replace"))
2293
- else:
2294
- monitor_fd = None
2295
-
2296
- if pty_eof:
2297
- break
2298
-
2299
- physical_rows, physical_cols = _term_size()
2300
- size_changed = (physical_rows != last_physical_rows) or (physical_cols != last_physical_cols)
2301
-
2302
- desired = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
2303
- if size_changed or (desired != effective_reserved_rows):
2304
- cr, cc = cursor.position()
2305
- if desired < effective_reserved_rows:
2306
- renderer.clear_reserved_area(physical_rows, physical_cols, effective_reserved_rows, cr, cc)
2307
-
2308
- effective_reserved_rows = desired
2309
- renderer.set_active_reserved_rows(effective_reserved_rows)
2310
-
2311
- child_rows = max(4, physical_rows - effective_reserved_rows)
2312
- child_cols = max(10, physical_cols)
2313
- _set_winsize(master_fd, child_rows, child_cols)
2314
- try:
2315
- os.killpg(child.pid, signal.SIGWINCH)
2316
- except Exception:
2317
- pass
2318
-
2319
- scroll_region_dirty = True
2320
- renderer.force_repaint(urgent=True) # Use urgent mode to ensure immediate repaint
2321
- last_physical_rows = physical_rows
2322
- last_physical_cols = physical_cols
2323
-
2324
- cr, cc = cursor.position()
2325
- if cr < 1:
2326
- cr = 1
2327
- if cc < 1:
2328
- cc = 1
2329
- if cr > child_rows:
2330
- cr = child_rows
2331
- if cc > child_cols:
2332
- cc = child_cols
2333
-
2334
- if scroll_region_dirty:
2335
- # Keep the reserved rows out of the terminal scroll region (esp. after resize).
2336
- try:
2337
- seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[{cr};{cc}H"
2338
- seq += "\\x1b[?25h" if cursor_visible else "\\x1b[?25l"
2339
- seq += "\\x1b[?2026l"
2340
- os.write(stdout_fd, seq.encode("utf-8", "ignore"))
2341
- except Exception:
2342
- pass
2343
- scroll_region_dirty = False
2344
-
2345
- # Periodic force repaint to ensure statusline doesn't disappear
2346
- now_ms = int(time.time() * 1000)
2347
- if now_ms - last_force_repaint_ms >= FORCE_REPAINT_INTERVAL_MS:
2348
- renderer.force_repaint(False)
2349
- last_force_repaint_ms = now_ms
2350
-
2351
- renderer.render(physical_rows, physical_cols, cr, cc)
2352
-
2353
- finally:
2354
- try:
2355
- termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty)
2356
- except Exception:
2357
- pass
2358
- try:
2359
- renderer.clear()
2360
- except Exception:
2361
- pass
2362
- try:
2363
- # Restore terminal scroll region and attributes.
2364
- os.write(stdout_fd, b"\\x1b[r\\x1b[0m\\x1b[?25h")
2365
- except Exception:
2366
- pass
2367
- try:
2368
- os.close(master_fd)
2369
- except Exception:
2370
- pass
2371
- if monitor is not None:
2372
- try:
2373
- monitor.terminate()
2374
- except Exception:
2375
- pass
2376
-
2377
- sys.exit(child.returncode or 0)
2378
-
2379
-
2380
- if __name__ == "__main__":
2381
- main()
1552
+ function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath) {
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));
2382
2496
  `;
2383
2497
  }
2384
- async function createStatuslineFiles(outputDir, execTargetPath, aliasName) {
2498
+ async function createStatuslineFiles(outputDir, execTargetPath, aliasName, sessionsScriptPath) {
2385
2499
  if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
2386
2500
  const monitorScriptPath = join(outputDir, `${aliasName}-statusline.js`);
2387
2501
  const wrapperScriptPath = join(outputDir, aliasName);
2388
2502
  await writeFile(monitorScriptPath, generateStatuslineMonitorScript());
2389
2503
  await chmod(monitorScriptPath, 493);
2390
- await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath));
2504
+ await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath, sessionsScriptPath));
2391
2505
  await chmod(wrapperScriptPath, 493);
2392
2506
  return {
2393
2507
  wrapperScript: wrapperScriptPath,
@@ -2395,6 +2509,294 @@ async function createStatuslineFiles(outputDir, execTargetPath, aliasName) {
2395
2509
  };
2396
2510
  }
2397
2511
 
2512
+ //#endregion
2513
+ //#region src/sessions-patch.ts
2514
+ /**
2515
+ * Generate sessions browser script (Node.js)
2516
+ */
2517
+ function generateSessionsBrowserScript(aliasName) {
2518
+ return `#!/usr/bin/env node
2519
+ // Droid Sessions Browser - Interactive selector
2520
+ // Auto-generated by droid-patch
2521
+
2522
+ const fs = require('fs');
2523
+ const path = require('path');
2524
+ const readline = require('readline');
2525
+ const { execSync, spawn } = require('child_process');
2526
+
2527
+ const FACTORY_HOME = path.join(require('os').homedir(), '.factory');
2528
+ const SESSIONS_ROOT = path.join(FACTORY_HOME, 'sessions');
2529
+ const ALIAS_NAME = ${JSON.stringify(aliasName)};
2530
+
2531
+ // ANSI
2532
+ const CYAN = '\\x1b[36m';
2533
+ const GREEN = '\\x1b[32m';
2534
+ const YELLOW = '\\x1b[33m';
2535
+ const RED = '\\x1b[31m';
2536
+ const DIM = '\\x1b[2m';
2537
+ const RESET = '\\x1b[0m';
2538
+ const BOLD = '\\x1b[1m';
2539
+ const CLEAR = '\\x1b[2J\\x1b[H';
2540
+ const HIDE_CURSOR = '\\x1b[?25l';
2541
+ const SHOW_CURSOR = '\\x1b[?25h';
2542
+
2543
+ function sanitizePath(p) {
2544
+ return p.replace(/:/g, '').replace(/[\\\\/]/g, '-');
2545
+ }
2546
+
2547
+ function parseSessionFile(jsonlPath, settingsPath) {
2548
+ const sessionId = path.basename(jsonlPath, '.jsonl');
2549
+ const stats = fs.statSync(jsonlPath);
2550
+
2551
+ const result = {
2552
+ id: sessionId,
2553
+ title: '',
2554
+ mtime: stats.mtimeMs,
2555
+ model: '',
2556
+ firstUserMsg: '',
2557
+ lastUserMsg: '',
2558
+ messageCount: 0,
2559
+ lastTimestamp: '',
2560
+ };
2561
+
2562
+ try {
2563
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
2564
+ const lines = content.split('\\n').filter(l => l.trim());
2565
+ const userMessages = [];
2566
+
2567
+ for (const line of lines) {
2568
+ try {
2569
+ const obj = JSON.parse(line);
2570
+ if (obj.type === 'session_start') {
2571
+ result.title = obj.title || '';
2572
+ } else if (obj.type === 'message') {
2573
+ result.messageCount++;
2574
+ if (obj.timestamp) result.lastTimestamp = obj.timestamp;
2575
+
2576
+ const msg = obj.message || {};
2577
+ if (msg.role === 'user' && Array.isArray(msg.content)) {
2578
+ for (const c of msg.content) {
2579
+ if (c && c.type === 'text' && c.text && !c.text.startsWith('<system-reminder>')) {
2580
+ userMessages.push(c.text.slice(0, 150).replace(/\\n/g, ' ').trim());
2581
+ break;
2582
+ }
2583
+ }
2584
+ }
2585
+ }
2586
+ } catch {}
2587
+ }
2588
+
2589
+ if (userMessages.length > 0) {
2590
+ result.firstUserMsg = userMessages[0];
2591
+ result.lastUserMsg = userMessages.length > 1 ? userMessages[userMessages.length - 1] : '';
2592
+ }
2593
+ } catch {}
2594
+
2595
+ if (fs.existsSync(settingsPath)) {
2596
+ try {
2597
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
2598
+ result.model = settings.model || '';
2599
+ } catch {}
2600
+ }
2601
+
2602
+ return result;
2603
+ }
2604
+
2605
+ function collectSessions() {
2606
+ const cwd = process.cwd();
2607
+ const cwdSanitized = sanitizePath(cwd);
2608
+ const sessions = [];
2609
+
2610
+ if (!fs.existsSync(SESSIONS_ROOT)) return sessions;
2611
+
2612
+ for (const wsDir of fs.readdirSync(SESSIONS_ROOT)) {
2613
+ if (wsDir !== cwdSanitized) continue;
2614
+
2615
+ const wsPath = path.join(SESSIONS_ROOT, wsDir);
2616
+ if (!fs.statSync(wsPath).isDirectory()) continue;
2617
+
2618
+ for (const file of fs.readdirSync(wsPath)) {
2619
+ if (!file.endsWith('.jsonl')) continue;
2620
+
2621
+ const sessionId = file.slice(0, -6);
2622
+ const jsonlPath = path.join(wsPath, file);
2623
+ const settingsPath = path.join(wsPath, sessionId + '.settings.json');
2624
+
2625
+ try {
2626
+ const session = parseSessionFile(jsonlPath, settingsPath);
2627
+ if (session.messageCount === 0 || !session.firstUserMsg) continue;
2628
+ sessions.push(session);
2629
+ } catch {}
2630
+ }
2631
+ }
2632
+
2633
+ sessions.sort((a, b) => b.mtime - a.mtime);
2634
+ return sessions.slice(0, 50);
2635
+ }
2636
+
2637
+ function formatTime(ts) {
2638
+ if (!ts) return '';
2639
+ try {
2640
+ const d = new Date(ts);
2641
+ return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
2642
+ } catch {
2643
+ return ts.slice(0, 16);
2644
+ }
2645
+ }
2646
+
2647
+ function truncate(s, len) {
2648
+ if (!s) return '';
2649
+ s = s.replace(/\\n/g, ' ');
2650
+ return s.length > len ? s.slice(0, len - 3) + '...' : s;
2651
+ }
2652
+
2653
+ function render(sessions, selected, offset, rows) {
2654
+ const cwd = process.cwd();
2655
+ const pageSize = rows - 6;
2656
+ const visible = sessions.slice(offset, offset + pageSize);
2657
+
2658
+ let out = CLEAR;
2659
+ out += BOLD + 'Sessions: ' + RESET + DIM + cwd + RESET + '\\n';
2660
+ out += DIM + '[↑/↓] Select [Enter] Resume [q] Quit' + RESET + '\\n\\n';
2661
+
2662
+ for (let i = 0; i < visible.length; i++) {
2663
+ const s = visible[i];
2664
+ const idx = offset + i;
2665
+ const isSelected = idx === selected;
2666
+ const prefix = isSelected ? GREEN + '▶ ' + RESET : ' ';
2667
+
2668
+ const title = truncate(s.title || '(no title)', 35);
2669
+ const time = formatTime(s.lastTimestamp);
2670
+ const model = truncate(s.model, 20);
2671
+
2672
+ if (isSelected) {
2673
+ out += prefix + YELLOW + title + RESET + '\\n';
2674
+ out += ' ' + DIM + 'ID: ' + RESET + CYAN + s.id + RESET + '\\n';
2675
+ out += ' ' + DIM + 'Last: ' + time + ' | Model: ' + model + ' | ' + s.messageCount + ' msgs' + RESET + '\\n';
2676
+ out += ' ' + DIM + 'First input: ' + RESET + truncate(s.firstUserMsg, 60) + '\\n';
2677
+ if (s.lastUserMsg && s.lastUserMsg !== s.firstUserMsg) {
2678
+ out += ' ' + DIM + 'Last input: ' + RESET + truncate(s.lastUserMsg, 60) + '\\n';
2679
+ }
2680
+ } else {
2681
+ out += prefix + title + DIM + ' (' + time + ')' + RESET + '\\n';
2682
+ }
2683
+ }
2684
+
2685
+ out += '\\n' + DIM + 'Page ' + (Math.floor(offset / pageSize) + 1) + '/' + Math.ceil(sessions.length / pageSize) + ' (' + sessions.length + ' sessions)' + RESET;
2686
+
2687
+ process.stdout.write(out);
2688
+ }
2689
+
2690
+ async function main() {
2691
+ const sessions = collectSessions();
2692
+
2693
+ if (sessions.length === 0) {
2694
+ console.log(RED + 'No sessions with interactions found in current directory' + RESET);
2695
+ process.exit(0);
2696
+ }
2697
+
2698
+ if (!process.stdin.isTTY) {
2699
+ for (const s of sessions) {
2700
+ console.log(s.id + ' ' + (s.title || '') + ' ' + formatTime(s.lastTimestamp));
2701
+ }
2702
+ process.exit(0);
2703
+ }
2704
+
2705
+ const rows = process.stdout.rows || 24;
2706
+ const pageSize = rows - 6;
2707
+ let selected = 0;
2708
+ let offset = 0;
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
+
2720
+ process.stdin.setRawMode(true);
2721
+ process.stdin.resume();
2722
+ process.stdout.write(HIDE_CURSOR);
2723
+
2724
+ render(sessions, selected, offset, rows);
2725
+
2726
+ const onKey = (key) => {
2727
+ const k = key.toString();
2728
+
2729
+ if (k === 'q' || k === '\\x03') { // q or Ctrl+C
2730
+ restoreTerminal();
2731
+ clearScreen();
2732
+ process.exit(0);
2733
+ }
2734
+
2735
+ if (k === '\\r' || k === '\\n') { // Enter
2736
+ // Stop reading input / stop reacting to arrow keys before handing off to droid.
2737
+ process.stdin.off('data', onKey);
2738
+ restoreTerminal();
2739
+ clearScreen();
2740
+ const session = sessions[selected];
2741
+ console.log(GREEN + 'Resuming session: ' + session.id + RESET);
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
+
2750
+ const child = spawn(ALIAS_NAME, ['--resume', session.id], { stdio: 'inherit' });
2751
+ child.on('exit', (code) => process.exit(code || 0));
2752
+ child.on('error', () => process.exit(1));
2753
+ return;
2754
+ }
2755
+
2756
+ if (k === '\\x1b[A' || k === 'k') { // Up
2757
+ if (selected > 0) {
2758
+ selected--;
2759
+ if (selected < offset) offset = Math.max(0, offset - 1);
2760
+ }
2761
+ } else if (k === '\\x1b[B' || k === 'j') { // Down
2762
+ if (selected < sessions.length - 1) {
2763
+ selected++;
2764
+ if (selected >= offset + pageSize) offset++;
2765
+ }
2766
+ } else if (k === '\\x1b[5~') { // Page Up
2767
+ selected = Math.max(0, selected - pageSize);
2768
+ offset = Math.max(0, offset - pageSize);
2769
+ } else if (k === '\\x1b[6~') { // Page Down
2770
+ selected = Math.min(sessions.length - 1, selected + pageSize);
2771
+ offset = Math.min(Math.max(0, sessions.length - pageSize), offset + pageSize);
2772
+ }
2773
+
2774
+ render(sessions, selected, offset, rows);
2775
+ };
2776
+
2777
+ process.stdin.on('data', onKey);
2778
+
2779
+ process.on('SIGINT', () => {
2780
+ restoreTerminal();
2781
+ clearScreen();
2782
+ process.exit(0);
2783
+ });
2784
+ }
2785
+
2786
+ main();
2787
+ `;
2788
+ }
2789
+ /**
2790
+ * Create sessions browser script file
2791
+ */
2792
+ async function createSessionsScript(outputDir, aliasName) {
2793
+ if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
2794
+ const sessionsScriptPath = join(outputDir, `${aliasName}-sessions.js`);
2795
+ await writeFile(sessionsScriptPath, generateSessionsBrowserScript(aliasName));
2796
+ await chmod(sessionsScriptPath, 493);
2797
+ return { sessionsScript: sessionsScriptPath };
2798
+ }
2799
+
2398
2800
  //#endregion
2399
2801
  //#region src/cli.ts
2400
2802
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -2447,13 +2849,14 @@ function findDefaultDroidPath() {
2447
2849
  for (const p of paths) if (existsSync(p)) return p;
2448
2850
  return join(home, ".droid", "bin", "droid");
2449
2851
  }
2450
- bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--statusline", "Enable a Claude-style statusline (terminal UI)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
2852
+ bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--statusline", "Enable a Claude-style statusline (terminal UI)").option("--sessions", "Enable sessions browser (--sessions flag in alias)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
2451
2853
  const alias = args?.[0];
2452
2854
  const isCustom = options["is-custom"];
2453
2855
  const skipLogin = options["skip-login"];
2454
2856
  const apiBase = options["api-base"];
2455
2857
  const websearch = options["websearch"];
2456
2858
  const statusline = options["statusline"];
2859
+ const sessions = options["sessions"];
2457
2860
  const standalone = options["standalone"];
2458
2861
  const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
2459
2862
  const reasoningEffort = options["reasoning-effort"];
@@ -2464,7 +2867,9 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2464
2867
  const backup = options.backup !== false;
2465
2868
  const verbose = options.verbose;
2466
2869
  const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
2467
- 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)) {
2468
2873
  if (!alias) {
2469
2874
  console.log(styleText("red", "Error: Alias name required for --websearch/--statusline"));
2470
2875
  console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
@@ -2480,15 +2885,18 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2480
2885
  console.log(styleText("white", `Forward target: ${websearchTarget}`));
2481
2886
  if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
2482
2887
  }
2483
- if (statusline) console.log(styleText("white", `Statusline: enabled`));
2888
+ if (statuslineEnabled) console.log(styleText("white", `Statusline: enabled`));
2484
2889
  console.log();
2485
2890
  let execTargetPath = path;
2486
2891
  if (websearch) {
2487
2892
  const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
2488
2893
  execTargetPath = wrapperScript;
2489
2894
  }
2490
- if (statusline) {
2491
- const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
2895
+ if (statuslineEnabled) {
2896
+ const statuslineDir = join(homedir(), ".droid-patch", "statusline");
2897
+ let sessionsScript;
2898
+ if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
2899
+ const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, alias, sessionsScript);
2492
2900
  execTargetPath = wrapperScript;
2493
2901
  }
2494
2902
  const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
@@ -2498,7 +2906,8 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2498
2906
  skipLogin: false,
2499
2907
  apiBase: apiBase || null,
2500
2908
  websearch: !!websearch,
2501
- statusline: !!statusline,
2909
+ statusline: !!statuslineEnabled,
2910
+ sessions: !!sessions,
2502
2911
  reasoningEffort: false,
2503
2912
  noTelemetry: false,
2504
2913
  standalone
@@ -2534,7 +2943,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2534
2943
  }
2535
2944
  return;
2536
2945
  }
2537
- if (!isCustom && !skipLogin && !apiBase && !websearch && !statusline && !reasoningEffort && !noTelemetry) {
2946
+ if (!isCustom && !skipLogin && !apiBase && !websearch && !statuslineEnabled && !reasoningEffort && !noTelemetry) {
2538
2947
  console.log(styleText("yellow", "No patch flags specified. Available patches:"));
2539
2948
  console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
2540
2949
  console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
@@ -2693,14 +3102,17 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2693
3102
  console.log(styleText("white", ` Forward target: ${websearchTarget}`));
2694
3103
  if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
2695
3104
  }
2696
- if (statusline) {
2697
- const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
3105
+ if (statuslineEnabled) {
3106
+ const statuslineDir = join(homedir(), ".droid-patch", "statusline");
3107
+ let sessionsScript;
3108
+ if (sessions) sessionsScript = (await createSessionsScript(statuslineDir, alias)).sessionsScript;
3109
+ const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, alias, sessionsScript);
2698
3110
  execTargetPath = wrapperScript;
2699
3111
  console.log();
2700
3112
  console.log(styleText("cyan", "Statusline enabled"));
2701
3113
  }
2702
3114
  let aliasResult;
2703
- if (websearch || statusline) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
3115
+ if (websearch || statuslineEnabled) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
2704
3116
  else aliasResult = await createAlias(result.outputPath, alias, verbose);
2705
3117
  const droidVersion = getDroidVersion(path);
2706
3118
  await saveAliasMetadata(createMetadata(alias, path, {
@@ -2708,7 +3120,8 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2708
3120
  skipLogin: !!skipLogin,
2709
3121
  apiBase: apiBase || null,
2710
3122
  websearch: !!websearch,
2711
- statusline: !!statusline,
3123
+ statusline: !!statuslineEnabled,
3124
+ sessions: !!sessions,
2712
3125
  reasoningEffort: !!reasoningEffort,
2713
3126
  noTelemetry: !!noTelemetry,
2714
3127
  standalone: !!standalone
@@ -2925,7 +3338,10 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
2925
3338
  }
2926
3339
  }
2927
3340
  if (meta.patches.statusline) {
2928
- const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, meta.name);
3341
+ const statuslineDir = join(homedir(), ".droid-patch", "statusline");
3342
+ let sessionsScript;
3343
+ if (meta.patches.sessions) sessionsScript = (await createSessionsScript(statuslineDir, meta.name)).sessionsScript;
3344
+ const { wrapperScript } = await createStatuslineFiles(statuslineDir, execTargetPath, meta.name, sessionsScript);
2929
3345
  execTargetPath = wrapperScript;
2930
3346
  if (verbose) console.log(styleText("gray", ` Regenerated statusline wrapper`));
2931
3347
  }