@xp266/dshtui 0.1.5 → 0.1.7

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/lib/dialog.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import "./prod-react-Dzof4qJx.mjs";
2
- import { I as panelAnchorRow, c as asTextItem, i as Dialog, l as registerWidget, n as panelLegend, o as rowHeight, r as CloseGuardContext, s as wrapStatusLines, t as PanelSurface } from "./surface-B7w3R_kh.mjs";
2
+ import { L as panelAnchorRow, c as asTextItem, i as Dialog, l as registerWidget, n as panelLegend, o as rowHeight, r as CloseGuardContext, s as wrapStatusLines, t as PanelSurface } from "./surface-DSDGGCSN.mjs";
3
3
  export { CloseGuardContext, Dialog, PanelSurface, asTextItem, panelAnchorRow, panelLegend, registerWidget, rowHeight, wrapStatusLines };
package/lib/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./prod-react-Dzof4qJx.mjs";
2
- import { $ as errorText, A as SelectableText, At as setColorLevel, B as inputFrameTop, C as caretNonceText, Ct as registerPalette, D as markKeymapConsumed, Dt as themeMode, E as isKeyConsumed, Et as subscribePalettes, F as messageBackgroundWidth, Ft as warn, G as wrapSegments, H as glyphs, I as panelAnchorRow, J as clampFocusRow, K as chromeSelectionText, L as panelContains, M as Region, Mt as configureLogs, N as hintSpan, Nt as error, O as createMouseController, Ot as keyedRegistry, P as inputContentContains, Pt as installCrashHandlers, Q as colToCharIndex, R as scrollbarColumn, S as caretNonceColor, St as permissionModeInfo, T as beginKeyEvent, Tt as setThemeMode, U as mergeRuns, V as inputStatusRow, W as segmentsKey, X as toScreenSelection, Y as selectedRange, Z as charWidth, _ as editDeleteWordRight, _t as releaseFields, a as DIALOG_MAX_HEIGHT, at as wrapLines, b as writeCursorShape, bt as COLORS, ct as fieldExpandOf, d as editCursorLeft, dt as hasFieldChar, et as lineBreaks, f as editCursorRight, ft as hasFieldSlots, g as editDeleteWordLeft, gt as registerFieldKind, h as editDelete, ht as linesChipLabel, i as Dialog, it as truncate, j as SelectionContext, jt as env, k as isMouseResidue, kt as colorLevel, l as registerWidget, lt as fieldSlotOf, m as editCursorWordRight, mt as isFieldChar, n as panelLegend, nt as segmentGraphemes, ot as allocateField, p as editCursorWordLeft, pt as imageChipLabel, q as sliceByColumns, r as CloseGuardContext, rt as textWidth, st as charactersChipLabel, t as PanelSurface, tt as locToPoint, u as editBackspace, ut as fieldStyleOf, v as editInsert, vt as releaseUnreferenced, w as useCaret, wt as replacePalettes, x as caretNonceBold, xt as paletteColor, y as CapText, yt as specialFieldFactory, z as hintBlockTop } from "./surface-B7w3R_kh.mjs";
2
+ import { $ as colToCharIndex, A as writeCursorShape, At as colorLevel, B as hintBlockTop, C as useCaret, Ct as permissionModeInfo, D as SelectableText, Dt as subscribePalettes, E as markKeymapConsumed, Et as setThemeMode, F as inputContentContains, Ft as installCrashHandlers, G as segmentsKey, H as inputStatusRow, I as messageBackgroundWidth, It as warn, J as sliceByColumns, K as wrapSegments, L as panelAnchorRow, M as isMouseResidue, Mt as env, N as writeMouseDisable, Nt as configureLogs, O as SelectionContext, Ot as themeMode, P as hintSpan, Pt as error, Q as charWidth, R as panelContains, S as caretNonceText, St as paletteColor, T as isKeyConsumed, Tt as replacePalettes, U as glyphs, V as inputFrameTop, W as mergeRuns, X as selectedRange, Y as clampFocusRow, Z as toScreenSelection, _ as editDeleteWordRight, _t as registerFieldKind, a as DIALOG_MAX_HEIGHT, at as truncate, b as caretNonceBold, bt as specialFieldFactory, ct as charactersChipLabel, d as editCursorLeft, dt as fieldStyleOf, et as errorText, f as editCursorRight, ft as hasFieldChar, g as editDeleteWordLeft, gt as linesChipLabel, h as editDelete, ht as isFieldChar, i as Dialog, it as textWidth, j as createMouseController, jt as setColorLevel, k as Region, kt as keyedRegistry, l as registerWidget, lt as fieldExpandOf, m as editCursorWordRight, mt as imageChipLabel, n as panelLegend, nt as locToPoint, ot as wrapLines, p as editCursorWordLeft, pt as hasFieldSlots, q as chromeSelectionText, r as CloseGuardContext, rt as segmentGraphemes, st as allocateField, t as PanelSurface, tt as lineBreaks, u as editBackspace, ut as fieldSlotOf, v as editInsert, vt as releaseFields, w as beginKeyEvent, wt as registerPalette, x as caretNonceColor, xt as COLORS, y as CapText, yt as releaseUnreferenced, z as scrollbarColumn } from "./surface-DSDGGCSN.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { readFileSync, statSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
@@ -7197,9 +7197,9 @@ function sessionTime(session) {
7197
7197
  * with the client-side visibility rules: live sessions plus persisted cold
7198
7198
  * sessions with a cwd, blank and subagent-origin rows hidden, newest-first,
7199
7199
  * without any workspace filtering (the picker labels ungrouped sessions).
7200
- * Cold rows read projection-cache hints or a size-gated small-artifact
7201
- * probe; a hint-less conversation title comes from the cache's cold ladder
7202
- * (one log read, then persisted), never a repeated full read.
7200
+ * Cold rows take their titles from the host's batched session-title fold
7201
+ * (`sessionQuery.readTitleSnapshots`), which reads each log once per
7202
+ * release of the query corpus and never loads full sessions into memory.
7203
7203
  */
7204
7204
  async function computeSessionList(ctx) {
7205
7205
  try {
@@ -7224,8 +7224,7 @@ async function computeSessionListInner(ctx) {
7224
7224
  const title = titleService?.get?.(session)?.title ?? firstUserText(session.snapshotEvents());
7225
7225
  summaries.push(toSummary(id, title, session.header.cwd, session.header.createdAt, lastPromptAt(session.snapshotEvents()), workspacePaths));
7226
7226
  }
7227
- const persistence = ctx.get("sessionPersistence");
7228
- await mergeColdSummaries(ctx, persistence, await listColdHeaders(ctx, persistence, attached), archived, workspacePaths, summaries);
7227
+ await mergeColdSummaries(ctx, await listColdHeaders(ctx, ctx.get("sessionPersistence"), attached), archived, workspacePaths, summaries);
7229
7228
  await attachModifiedTimes(ctx, summaries);
7230
7229
  return summaries.sort((a, b) => sessionTime(b) - sessionTime(a));
7231
7230
  }
@@ -7286,102 +7285,26 @@ function lastPromptAt(events) {
7286
7285
  for (const event of events) if (event.type === "user/message" && event.data.source.kind === "user") last = event.time;
7287
7286
  return last;
7288
7287
  }
7289
- const SESSION_META_CACHE_MAX = 512;
7290
- const sessionMetaCache = /* @__PURE__ */ new Map();
7291
- function cacheSessionMeta(id, entry) {
7292
- while (sessionMetaCache.size >= SESSION_META_CACHE_MAX) {
7293
- const oldest = sessionMetaCache.keys().next();
7294
- if (oldest.done) break;
7295
- sessionMetaCache.delete(oldest.value);
7296
- }
7297
- sessionMetaCache.set(id, entry);
7298
- }
7299
- const PROBE_BATCH_SIZE = 16;
7300
- /** Mirrors the host's cold blank-probe gate: only small artifacts are read. */
7301
- const COLD_PROBE_MAX_BYTES = 1024;
7302
- function listMetadataHint(values) {
7303
- const hint = values.sessionListMetadata;
7304
- if (typeof hint?.blank !== "boolean") return void 0;
7305
- return {
7306
- blank: hint.blank,
7307
- lastPromptAt: typeof hint.lastPromptAt === "number" ? hint.lastPromptAt : null
7308
- };
7309
- }
7310
- /**
7311
- * Cold-session metadata without unconditional full-log reads: projection
7312
- * hints first (zero I/O), then a direct read only when the artifact is small
7313
- * enough to be a blank-session log. A hint-less oversized artifact is
7314
- * conservatively visible; its title comes from the projection cache's cold
7315
- * ladder, which reads the log once and writes the row back durably.
7316
- */
7317
- async function resolveColdMeta(cache, persistence, header) {
7318
- let titleHint;
7319
- let metadata;
7320
- try {
7321
- const values = cache?.cachedSnapshot?.(header)?.values ?? {};
7322
- if (typeof values.title === "string" && values.title !== "") titleHint = values.title;
7323
- metadata = listMetadataHint(values);
7324
- } catch {}
7325
- if (metadata?.blank === false) return {
7326
- title: await coldTitle(cache, header, titleHint),
7327
- blank: false,
7328
- promptAt: metadata.lastPromptAt ?? 0
7329
- };
7330
- const probed = await probeSmallArtifact(persistence, header);
7331
- if (probed !== void 0) return {
7332
- title: probed.title ?? titleHint,
7333
- blank: probed.blank,
7334
- promptAt: probed.promptAt
7335
- };
7336
- return {
7337
- title: await coldTitle(cache, header, titleHint),
7338
- blank: false,
7339
- promptAt: metadata?.lastPromptAt ?? 0
7340
- };
7341
- }
7342
- async function coldTitle(cache, header, titleHint) {
7343
- if (titleHint !== void 0 || cache?.coldSnapshot === void 0) return titleHint;
7344
- try {
7345
- const title = (await cache.coldSnapshot(String(header.id)))?.values?.title;
7346
- return typeof title === "string" && title !== "" ? title : void 0;
7347
- } catch {
7348
- return;
7349
- }
7350
- }
7351
- async function probeSmallArtifact(persistence, header) {
7352
- if (persistence.locate === void 0 || persistence.readFrom === void 0) return void 0;
7353
- const location = persistence.locate(header);
7354
- if (location === void 0) return void 0;
7355
- try {
7356
- if ((await stat(location.path)).size > COLD_PROBE_MAX_BYTES) return void 0;
7357
- const { events } = await persistence.readFrom(String(header.id), 0);
7358
- return {
7359
- blank: isBlankSession(events),
7360
- title: titleFromEvents(events) ?? firstUserText(events),
7361
- promptAt: lastPromptAt(events)
7362
- };
7363
- } catch {
7364
- return;
7288
+ async function mergeColdSummaries(ctx, cold, archived, workspacePaths, summaries) {
7289
+ const query = ctx.get("sessionQuery");
7290
+ if (query?.readTitleSnapshots === void 0) return;
7291
+ const observed = await query.readTitleSnapshots(cold.map((header) => String(header.id))).catch(() => []);
7292
+ const titleById = /* @__PURE__ */ new Map();
7293
+ for (const entry of observed) {
7294
+ if (entry.status !== "fulfilled") continue;
7295
+ const snapshot = entry.value?.title;
7296
+ if (snapshot?.title === void 0 || snapshot.title === "") continue;
7297
+ titleById.set(String(entry.sessionId), {
7298
+ title: snapshot.title,
7299
+ promptAt: snapshot.updatedAt ?? 0
7300
+ });
7365
7301
  }
7366
- }
7367
- async function mergeColdSummaries(ctx, persistence, cold, archived, workspacePaths, summaries) {
7368
- if (persistence === void 0) return;
7369
- const cache = ctx.get("sessionProjectionCache");
7370
- const pending = [];
7371
7302
  for (const header of cold) {
7372
7303
  if (archived.has(String(header.id))) continue;
7373
- const cached = sessionMetaCache.get(String(header.id));
7374
- if (cached !== void 0 && cached.blank !== void 0) {
7375
- if (!cached.blank) summaries.push(toSummary(String(header.id), cached.title, header.cwd, header.createdAt, cached.promptAt, workspacePaths));
7376
- continue;
7377
- }
7378
- pending.push(header);
7304
+ const meta = titleById.get(String(header.id));
7305
+ if (meta === void 0) continue;
7306
+ summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths));
7379
7307
  }
7380
- for (let index = 0; index < pending.length; index += PROBE_BATCH_SIZE) await Promise.all(pending.slice(index, index + PROBE_BATCH_SIZE).map(async (header) => {
7381
- const meta = await resolveColdMeta(cache, persistence, header);
7382
- cacheSessionMeta(String(header.id), meta);
7383
- if (!meta.blank) summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths));
7384
- }));
7385
7308
  }
7386
7309
  function fallbackName(id, directory) {
7387
7310
  if (directory !== void 0 && directory !== "") {
@@ -7390,14 +7313,6 @@ function fallbackName(id, directory) {
7390
7313
  }
7391
7314
  return id.match(/[^/]+$/)?.[0] ?? id;
7392
7315
  }
7393
- function titleFromEvents(events) {
7394
- let title;
7395
- for (const event of events) if (event.type === "session/title") {
7396
- const data = event.data;
7397
- if (data?.title !== void 0 && data.title !== "") title = data.title;
7398
- }
7399
- return title;
7400
- }
7401
7316
  function firstUserText(events) {
7402
7317
  for (const event of events) {
7403
7318
  if (event.type !== "user/message") continue;
@@ -9227,6 +9142,8 @@ function App({ bridge, screen, themeTick = 0 }) {
9227
9142
  }
9228
9143
  if (!exiting.current) {
9229
9144
  exiting.current = true;
9145
+ writeCursorShape("reset");
9146
+ writeMouseDisable();
9230
9147
  process.kill(process.pid, "SIGINT");
9231
9148
  setTimeout(() => process.exit(0), FORCE_EXIT_DELAY_MS).unref();
9232
9149
  }
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { Box, Text, useCursor, useInput, usePaste, useStdout } from "ink";
@@ -1298,6 +1298,154 @@ function scrollbarColumn(columns) {
1298
1298
  function messageBackgroundWidth(width) {
1299
1299
  return width - 2 - 3 - 1;
1300
1300
  }
1301
+ //#endregion
1302
+ //#region src/terminal/mouse.ts
1303
+ const MOUSE_ENABLE_SEQUENCES = "\x1B[?1003l\x1B[?1000h\x1B[?1002h\x1B[?1006h";
1304
+ const MOUSE_DISABLE_SEQUENCES = "\x1B[?1003l\x1B[?1000l\x1B[?1002l\x1B[?1006l";
1305
+ /**
1306
+ * Terminal-mode sequences must reach the device even when the process exits
1307
+ * right after: on Windows, TTY writes through process.stdout are
1308
+ * asynchronous and a trailing restore sequence is silently dropped, leaving
1309
+ * the terminal in mouse-tracking mode. fd 1 writes are synchronous.
1310
+ */
1311
+ function writeStdoutRaw(text) {
1312
+ try {
1313
+ writeSync(1, text);
1314
+ } catch {
1315
+ process.stdout.write(text);
1316
+ }
1317
+ }
1318
+ function writeMouseEnable() {
1319
+ writeStdoutRaw(MOUSE_ENABLE_SEQUENCES);
1320
+ }
1321
+ function writeMouseDisable() {
1322
+ writeStdoutRaw(MOUSE_DISABLE_SEQUENCES);
1323
+ }
1324
+ const MAX_BUFFER = 64;
1325
+ const SGR_PREFIX = "\x1B[<";
1326
+ function partialPrefixLength(buffer) {
1327
+ for (let keep = Math.min(2, buffer.length); keep > 0; keep--) if (SGR_PREFIX.startsWith(buffer.slice(-keep))) return keep;
1328
+ return 0;
1329
+ }
1330
+ function createMouseParser(onEvent) {
1331
+ let buffer = "";
1332
+ const pressedButtons = /* @__PURE__ */ new Set();
1333
+ function parseSequenceAt(str, offset) {
1334
+ if (!str.startsWith("\x1B[<", offset)) return null;
1335
+ let index = offset + 3;
1336
+ const values = [];
1337
+ let current = 0;
1338
+ let hasDigit = false;
1339
+ let terminator = "";
1340
+ for (; index < str.length; index++) {
1341
+ const ch = str[index];
1342
+ if (ch >= "0" && ch <= "9") {
1343
+ current = current * 10 + (ch.charCodeAt(0) - 48);
1344
+ hasDigit = true;
1345
+ } else if (ch === ";" && hasDigit) {
1346
+ values.push(current);
1347
+ current = 0;
1348
+ hasDigit = false;
1349
+ } else if (ch === "M" || ch === "m") {
1350
+ if (!hasDigit) return null;
1351
+ values.push(current);
1352
+ terminator = ch;
1353
+ break;
1354
+ } else return null;
1355
+ }
1356
+ if (terminator === "") return null;
1357
+ if (values.length !== 3) return null;
1358
+ return {
1359
+ event: decodeSgrEvent(values[0], values[1], values[2], terminator, pressedButtons),
1360
+ consumed: index - offset + 1
1361
+ };
1362
+ }
1363
+ return { feed(chunk) {
1364
+ buffer += chunk;
1365
+ while (true) {
1366
+ const start = buffer.indexOf("\x1B[<");
1367
+ if (start === -1) {
1368
+ const keep = partialPrefixLength(buffer);
1369
+ buffer = keep === 0 ? "" : buffer.slice(-keep);
1370
+ return;
1371
+ }
1372
+ if (start > 0) {
1373
+ buffer = buffer.slice(start);
1374
+ continue;
1375
+ }
1376
+ if (buffer.length > MAX_BUFFER) {
1377
+ buffer = "";
1378
+ return;
1379
+ }
1380
+ const parsed = parseSequenceAt(buffer, 0);
1381
+ if (parsed === null) return;
1382
+ const { event, consumed } = parsed;
1383
+ buffer = buffer.slice(consumed);
1384
+ onEvent(event);
1385
+ }
1386
+ } };
1387
+ }
1388
+ function decodeSgrEvent(rawButtonCode, wireX, wireY, pressRelease, pressed) {
1389
+ const button = rawButtonCode & 3;
1390
+ const isScroll = (rawButtonCode & 64) !== 0;
1391
+ const isMotion = (rawButtonCode & 32) !== 0;
1392
+ const modifiers = {
1393
+ shift: (rawButtonCode & 4) !== 0,
1394
+ alt: (rawButtonCode & 8) !== 0,
1395
+ ctrl: (rawButtonCode & 16) !== 0
1396
+ };
1397
+ let type;
1398
+ let scrollDirection;
1399
+ if (isScroll) {
1400
+ type = "scroll";
1401
+ scrollDirection = button === 0 ? "up" : "down";
1402
+ } else if (isMotion) type = pressed.size > 0 ? "drag" : "move";
1403
+ else if (pressRelease === "M") {
1404
+ type = "down";
1405
+ if (button !== 3) pressed.add(button);
1406
+ } else {
1407
+ type = "up";
1408
+ if (button === 3) pressed.clear();
1409
+ else pressed.delete(button);
1410
+ }
1411
+ return {
1412
+ type,
1413
+ button: button === 3 ? 0 : button,
1414
+ x: wireX - 1,
1415
+ y: wireY - 1,
1416
+ modifiers,
1417
+ ...scrollDirection === void 0 ? {} : { scrollDirection }
1418
+ };
1419
+ }
1420
+ function isMouseResidue(input) {
1421
+ return input.includes("\x1B") || /^\[<\d+(;\d+)*[Mm]$/.test(input);
1422
+ }
1423
+ function createMouseController(onEvent) {
1424
+ const parser = createMouseParser(onEvent);
1425
+ const onData = (chunk) => parser.feed(chunk.toString("latin1"));
1426
+ return {
1427
+ enable() {
1428
+ writeMouseEnable();
1429
+ process.stdin.on("data", onData);
1430
+ },
1431
+ disable() {
1432
+ writeMouseDisable();
1433
+ process.stdin.off("data", onData);
1434
+ }
1435
+ };
1436
+ }
1437
+ //#endregion
1438
+ //#region src/terminal/cursor-shape.ts
1439
+ const SEQUENCES = {
1440
+ beam: "\x1B[1 q",
1441
+ block: "\x1B[2 q",
1442
+ reset: "\x1B[0 q",
1443
+ hide: "\x1B[?25l",
1444
+ show: "\x1B[?25h"
1445
+ };
1446
+ function writeCursorShape(shape) {
1447
+ writeStdoutRaw(SEQUENCES[shape]);
1448
+ }
1301
1449
  const RegionContext = createContext({
1302
1450
  x: 0,
1303
1451
  y: 0
@@ -1435,129 +1583,6 @@ function renderSegments(segments, start, end, baseColor) {
1435
1583
  return out;
1436
1584
  }
1437
1585
  //#endregion
1438
- //#region src/terminal/mouse.ts
1439
- const MOUSE_ENABLE_SEQUENCES = "\x1B[?1003l\x1B[?1000h\x1B[?1002h\x1B[?1006h";
1440
- const MOUSE_DISABLE_SEQUENCES = "\x1B[?1003l\x1B[?1000l\x1B[?1002l\x1B[?1006l";
1441
- function writeMouseEnable() {
1442
- process.stdout.write(MOUSE_ENABLE_SEQUENCES);
1443
- }
1444
- function writeMouseDisable() {
1445
- process.stdout.write(MOUSE_DISABLE_SEQUENCES);
1446
- }
1447
- const MAX_BUFFER = 64;
1448
- const SGR_PREFIX = "\x1B[<";
1449
- function partialPrefixLength(buffer) {
1450
- for (let keep = Math.min(2, buffer.length); keep > 0; keep--) if (SGR_PREFIX.startsWith(buffer.slice(-keep))) return keep;
1451
- return 0;
1452
- }
1453
- function createMouseParser(onEvent) {
1454
- let buffer = "";
1455
- const pressedButtons = /* @__PURE__ */ new Set();
1456
- function parseSequenceAt(str, offset) {
1457
- if (!str.startsWith("\x1B[<", offset)) return null;
1458
- let index = offset + 3;
1459
- const values = [];
1460
- let current = 0;
1461
- let hasDigit = false;
1462
- let terminator = "";
1463
- for (; index < str.length; index++) {
1464
- const ch = str[index];
1465
- if (ch >= "0" && ch <= "9") {
1466
- current = current * 10 + (ch.charCodeAt(0) - 48);
1467
- hasDigit = true;
1468
- } else if (ch === ";" && hasDigit) {
1469
- values.push(current);
1470
- current = 0;
1471
- hasDigit = false;
1472
- } else if (ch === "M" || ch === "m") {
1473
- if (!hasDigit) return null;
1474
- values.push(current);
1475
- terminator = ch;
1476
- break;
1477
- } else return null;
1478
- }
1479
- if (terminator === "") return null;
1480
- if (values.length !== 3) return null;
1481
- return {
1482
- event: decodeSgrEvent(values[0], values[1], values[2], terminator, pressedButtons),
1483
- consumed: index - offset + 1
1484
- };
1485
- }
1486
- return { feed(chunk) {
1487
- buffer += chunk;
1488
- while (true) {
1489
- const start = buffer.indexOf("\x1B[<");
1490
- if (start === -1) {
1491
- const keep = partialPrefixLength(buffer);
1492
- buffer = keep === 0 ? "" : buffer.slice(-keep);
1493
- return;
1494
- }
1495
- if (start > 0) {
1496
- buffer = buffer.slice(start);
1497
- continue;
1498
- }
1499
- if (buffer.length > MAX_BUFFER) {
1500
- buffer = "";
1501
- return;
1502
- }
1503
- const parsed = parseSequenceAt(buffer, 0);
1504
- if (parsed === null) return;
1505
- const { event, consumed } = parsed;
1506
- buffer = buffer.slice(consumed);
1507
- onEvent(event);
1508
- }
1509
- } };
1510
- }
1511
- function decodeSgrEvent(rawButtonCode, wireX, wireY, pressRelease, pressed) {
1512
- const button = rawButtonCode & 3;
1513
- const isScroll = (rawButtonCode & 64) !== 0;
1514
- const isMotion = (rawButtonCode & 32) !== 0;
1515
- const modifiers = {
1516
- shift: (rawButtonCode & 4) !== 0,
1517
- alt: (rawButtonCode & 8) !== 0,
1518
- ctrl: (rawButtonCode & 16) !== 0
1519
- };
1520
- let type;
1521
- let scrollDirection;
1522
- if (isScroll) {
1523
- type = "scroll";
1524
- scrollDirection = button === 0 ? "up" : "down";
1525
- } else if (isMotion) type = pressed.size > 0 ? "drag" : "move";
1526
- else if (pressRelease === "M") {
1527
- type = "down";
1528
- if (button !== 3) pressed.add(button);
1529
- } else {
1530
- type = "up";
1531
- if (button === 3) pressed.clear();
1532
- else pressed.delete(button);
1533
- }
1534
- return {
1535
- type,
1536
- button: button === 3 ? 0 : button,
1537
- x: wireX - 1,
1538
- y: wireY - 1,
1539
- modifiers,
1540
- ...scrollDirection === void 0 ? {} : { scrollDirection }
1541
- };
1542
- }
1543
- function isMouseResidue(input) {
1544
- return input.includes("\x1B") || /^\[<\d+(;\d+)*[Mm]$/.test(input);
1545
- }
1546
- function createMouseController(onEvent) {
1547
- const parser = createMouseParser(onEvent);
1548
- const onData = (chunk) => parser.feed(chunk.toString("latin1"));
1549
- return {
1550
- enable() {
1551
- writeMouseEnable();
1552
- process.stdin.on("data", onData);
1553
- },
1554
- disable() {
1555
- writeMouseDisable();
1556
- process.stdin.off("data", onData);
1557
- }
1558
- };
1559
- }
1560
- //#endregion
1561
1586
  //#region src/ui/key-arbiter.ts
1562
1587
  /**
1563
1588
  * Key-event suppression for ink's broadcast-based useInput.
@@ -1626,18 +1651,6 @@ function caretNonceBold(cursor) {
1626
1651
  return Math.floor(cursor / 480) % 2 === 0;
1627
1652
  }
1628
1653
  //#endregion
1629
- //#region src/terminal/cursor-shape.ts
1630
- const SEQUENCES = {
1631
- beam: "\x1B[1 q",
1632
- block: "\x1B[2 q",
1633
- reset: "\x1B[0 q",
1634
- hide: "\x1B[?25l",
1635
- show: "\x1B[?25h"
1636
- };
1637
- function writeCursorShape(shape) {
1638
- process.stdout.write(SEQUENCES[shape]);
1639
- }
1640
- //#endregion
1641
1654
  //#region src/ui/chrome/caps.tsx
1642
1655
  function CapText({ background, top, width }) {
1643
1656
  if (glyphs.halfBlockCaps) return /* @__PURE__ */ jsx(Text, {
@@ -3123,4 +3136,4 @@ function PanelSurface({ columns, rows, body, bodyStart, background, blockWidth }
3123
3136
  }) });
3124
3137
  }
3125
3138
  //#endregion
3126
- export { errorText as $, SelectableText as A, setColorLevel as At, inputFrameTop as B, caretNonceText as C, registerPalette as Ct, markKeymapConsumed as D, themeMode as Dt, isKeyConsumed as E, subscribePalettes as Et, messageBackgroundWidth as F, warn as Ft, wrapSegments as G, glyphs as H, panelAnchorRow as I, clampFocusRow as J, chromeSelectionText as K, panelContains as L, Region as M, configureLogs as Mt, hintSpan as N, error as Nt, createMouseController as O, keyedRegistry as Ot, inputContentContains as P, installCrashHandlers as Pt, colToCharIndex as Q, scrollbarColumn as R, caretNonceColor as S, permissionModeInfo as St, beginKeyEvent as T, setThemeMode as Tt, mergeRuns as U, inputStatusRow as V, segmentsKey as W, toScreenSelection as X, selectedRange as Y, charWidth as Z, editDeleteWordRight as _, releaseFields as _t, DIALOG_MAX_HEIGHT as a, wrapLines as at, writeCursorShape as b, COLORS as bt, asTextItem as c, fieldExpandOf as ct, editCursorLeft as d, hasFieldChar as dt, lineBreaks as et, editCursorRight as f, hasFieldSlots as ft, editDeleteWordLeft as g, registerFieldKind as gt, editDelete as h, linesChipLabel as ht, Dialog as i, truncate as it, SelectionContext as j, env as jt, isMouseResidue as k, colorLevel as kt, registerWidget as l, fieldSlotOf as lt, editCursorWordRight as m, isFieldChar as mt, panelLegend as n, segmentGraphemes as nt, rowHeight as o, allocateField as ot, editCursorWordLeft as p, imageChipLabel as pt, sliceByColumns as q, CloseGuardContext as r, textWidth as rt, wrapStatusLines as s, charactersChipLabel as st, PanelSurface as t, locToPoint as tt, editBackspace as u, fieldStyleOf as ut, editInsert as v, releaseUnreferenced as vt, useCaret as w, replacePalettes as wt, caretNonceBold as x, paletteColor as xt, CapText as y, specialFieldFactory as yt, hintBlockTop as z };
3139
+ export { colToCharIndex as $, writeCursorShape as A, colorLevel as At, hintBlockTop as B, useCaret as C, permissionModeInfo as Ct, SelectableText as D, subscribePalettes as Dt, markKeymapConsumed as E, setThemeMode as Et, inputContentContains as F, installCrashHandlers as Ft, segmentsKey as G, inputStatusRow as H, messageBackgroundWidth as I, warn as It, sliceByColumns as J, wrapSegments as K, panelAnchorRow as L, isMouseResidue as M, env as Mt, writeMouseDisable as N, configureLogs as Nt, SelectionContext as O, themeMode as Ot, hintSpan as P, error as Pt, charWidth as Q, panelContains as R, caretNonceText as S, paletteColor as St, isKeyConsumed as T, replacePalettes as Tt, glyphs as U, inputFrameTop as V, mergeRuns as W, selectedRange as X, clampFocusRow as Y, toScreenSelection as Z, editDeleteWordRight as _, registerFieldKind as _t, DIALOG_MAX_HEIGHT as a, truncate as at, caretNonceBold as b, specialFieldFactory as bt, asTextItem as c, charactersChipLabel as ct, editCursorLeft as d, fieldStyleOf as dt, errorText as et, editCursorRight as f, hasFieldChar as ft, editDeleteWordLeft as g, linesChipLabel as gt, editDelete as h, isFieldChar as ht, Dialog as i, textWidth as it, createMouseController as j, setColorLevel as jt, Region as k, keyedRegistry as kt, registerWidget as l, fieldExpandOf as lt, editCursorWordRight as m, imageChipLabel as mt, panelLegend as n, locToPoint as nt, rowHeight as o, wrapLines as ot, editCursorWordLeft as p, hasFieldSlots as pt, chromeSelectionText as q, CloseGuardContext as r, segmentGraphemes as rt, wrapStatusLines as s, allocateField as st, PanelSurface as t, lineBreaks as tt, editBackspace as u, fieldSlotOf as ut, editInsert as v, releaseFields as vt, beginKeyEvent as w, registerPalette as wt, caretNonceColor as x, COLORS as xt, CapText as y, releaseUnreferenced as yt, scrollbarColumn as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xp266/dshtui",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "ink-based terminal UI plugin for DeepSeek Harness",
6
6
  "engines": {
@@ -23,9 +23,9 @@ export function sessionTime(session: SessionSummary): number {
23
23
  * with the client-side visibility rules: live sessions plus persisted cold
24
24
  * sessions with a cwd, blank and subagent-origin rows hidden, newest-first,
25
25
  * without any workspace filtering (the picker labels ungrouped sessions).
26
- * Cold rows read projection-cache hints or a size-gated small-artifact
27
- * probe; a hint-less conversation title comes from the cache's cold ladder
28
- * (one log read, then persisted), never a repeated full read.
26
+ * Cold rows take their titles from the host's batched session-title fold
27
+ * (`sessionQuery.readTitleSnapshots`), which reads each log once per
28
+ * release of the query corpus and never loads full sessions into memory.
29
29
  */
30
30
  export async function computeSessionList(ctx: Context): Promise<SessionSummary[]> {
31
31
  try {
@@ -55,7 +55,7 @@ async function computeSessionListInner(ctx: Context): Promise<SessionSummary[]>
55
55
  }
56
56
  const persistence = ctx.get('sessionPersistence') as PersistenceLike | undefined
57
57
  const cold = await listColdHeaders(ctx, persistence, attached)
58
- await mergeColdSummaries(ctx, persistence, cold, archived, workspacePaths, summaries)
58
+ await mergeColdSummaries(ctx, cold, archived, workspacePaths, summaries)
59
59
  await attachModifiedTimes(ctx, summaries)
60
60
  return summaries.sort((a, b) => sessionTime(b) - sessionTime(a))
61
61
  }
@@ -67,13 +67,18 @@ interface ColdHeader {
67
67
  origin?: string
68
68
  }
69
69
 
70
- interface SessionQueryLike {
71
- listSessions?(signal?: AbortSignal): Promise<Array<{ header: ColdHeader; live: boolean; persisted: boolean }>>
70
+ interface PersistenceLike {
71
+ list(signal?: AbortSignal): Promise<ColdHeader[]>
72
+ locate?(header: { cwd?: string; id: string }): { path: string } | undefined
72
73
  }
73
74
 
74
- interface ProjectionCacheLike {
75
- cachedSnapshot?(meta: ColdHeader): { asOfSeq: number; values: Record<string, unknown> } | undefined
76
- coldSnapshot?(id: string, signal?: AbortSignal): Promise<{ asOfSeq: number; values: Record<string, unknown> } | undefined>
75
+ interface SessionQueryLike {
76
+ listSessions?(signal?: AbortSignal): Promise<Array<{ header: ColdHeader; live: boolean; persisted: boolean }>>
77
+ readTitleSnapshots?(sessionIds: readonly string[]): Promise<Array<{
78
+ sessionId: string
79
+ status: 'fulfilled' | 'rejected'
80
+ value?: { title?: { title?: string; updatedAt?: number } }
81
+ }>>
77
82
  }
78
83
 
79
84
  /**
@@ -159,134 +164,30 @@ function lastPromptAt(events: readonly SessionEvent[]): number {
159
164
  return last
160
165
  }
161
166
 
162
- interface PersistenceLike {
163
- list(signal?: AbortSignal): Promise<ColdHeader[]>
164
- locate?(header: { cwd?: string; id: string }): { path: string } | undefined
165
- readFrom?(id: string, offset: number, signal?: AbortSignal): Promise<{ events: SessionEvent[] }>
166
- }
167
-
168
- interface SessionMetaCacheEntry {
169
- title: string | undefined
170
- blank: boolean | undefined
171
- promptAt: number
172
- }
173
-
174
- const SESSION_META_CACHE_MAX = 512
175
- const sessionMetaCache = new Map<string, SessionMetaCacheEntry>()
176
-
177
- function cacheSessionMeta(id: string, entry: SessionMetaCacheEntry): void {
178
- while (sessionMetaCache.size >= SESSION_META_CACHE_MAX) {
179
- const oldest = sessionMetaCache.keys().next()
180
- if (oldest.done) break
181
- sessionMetaCache.delete(oldest.value)
182
- }
183
- sessionMetaCache.set(id, entry)
184
- }
185
-
186
- const PROBE_BATCH_SIZE = 16
187
-
188
- /** Mirrors the host's cold blank-probe gate: only small artifacts are read. */
189
- const COLD_PROBE_MAX_BYTES = 1024
190
-
191
- interface ColdMeta {
192
- title: string | undefined
193
- blank: boolean
194
- promptAt: number
195
- }
196
-
197
- function listMetadataHint(values: Record<string, unknown>): { blank: boolean; lastPromptAt: number | null } | undefined {
198
- const hint = values.sessionListMetadata as { blank?: unknown; lastPromptAt?: unknown } | undefined
199
- if (typeof hint?.blank !== 'boolean') return undefined
200
- return {
201
- blank: hint.blank,
202
- lastPromptAt: typeof hint.lastPromptAt === 'number' ? hint.lastPromptAt : null,
203
- }
204
- }
205
-
206
- /**
207
- * Cold-session metadata without unconditional full-log reads: projection
208
- * hints first (zero I/O), then a direct read only when the artifact is small
209
- * enough to be a blank-session log. A hint-less oversized artifact is
210
- * conservatively visible; its title comes from the projection cache's cold
211
- * ladder, which reads the log once and writes the row back durably.
212
- */
213
- async function resolveColdMeta(cache: ProjectionCacheLike | undefined, persistence: PersistenceLike, header: ColdHeader): Promise<ColdMeta> {
214
- let titleHint: string | undefined
215
- let metadata: { blank: boolean; lastPromptAt: number | null } | undefined
216
- try {
217
- const values = cache?.cachedSnapshot?.(header)?.values ?? {}
218
- if (typeof values.title === 'string' && values.title !== '') titleHint = values.title
219
- metadata = listMetadataHint(values)
220
- } catch {
221
- // A broken projection cache must not block the session picker; fall back to probing.
222
- }
223
- if (metadata?.blank === false) {
224
- return { title: await coldTitle(cache, header, titleHint), blank: false, promptAt: metadata.lastPromptAt ?? 0 }
225
- }
226
- const probed = await probeSmallArtifact(persistence, header)
227
- if (probed !== undefined) {
228
- return { title: probed.title ?? titleHint, blank: probed.blank, promptAt: probed.promptAt }
229
- }
230
- return { title: await coldTitle(cache, header, titleHint), blank: false, promptAt: metadata?.lastPromptAt ?? 0 }
231
- }
232
-
233
- async function coldTitle(cache: ProjectionCacheLike | undefined, header: ColdHeader, titleHint: string | undefined): Promise<string | undefined> {
234
- if (titleHint !== undefined || cache?.coldSnapshot === undefined) return titleHint
235
- try {
236
- const snapshot = await cache.coldSnapshot(String(header.id))
237
- const title = snapshot?.values?.title
238
- return typeof title === 'string' && title !== '' ? title : undefined
239
- } catch {
240
- // A failed cold-snapshot read leaves the session without a title.
241
- return undefined
242
- }
243
- }
244
-
245
- async function probeSmallArtifact(persistence: PersistenceLike, header: ColdHeader): Promise<ColdMeta | undefined> {
246
- if (persistence.locate === undefined || persistence.readFrom === undefined) return undefined
247
- const location = persistence.locate(header)
248
- if (location === undefined) return undefined
249
- try {
250
- const info = await stat(location.path)
251
- if (info.size > COLD_PROBE_MAX_BYTES) return undefined
252
- const { events } = await persistence.readFrom(String(header.id), 0)
253
- return { blank: isBlankSession(events), title: titleFromEvents(events) ?? firstUserText(events), promptAt: lastPromptAt(events) }
254
- } catch {
255
- // An unreadable artifact is treated as a blank session (skipped in the list).
256
- return undefined
257
- }
258
- }
259
-
260
167
  async function mergeColdSummaries(
261
168
  ctx: Context,
262
- persistence: PersistenceLike | undefined,
263
169
  cold: ColdHeader[],
264
170
  archived: ReadonlySet<string>,
265
171
  workspacePaths: ReadonlySet<string>,
266
172
  summaries: SessionSummary[],
267
173
  ): Promise<void> {
268
- if (persistence === undefined) return
269
- const cache = ctx.get('sessionProjectionCache') as ProjectionCacheLike | undefined
270
- const pending: ColdHeader[] = []
174
+ const query = ctx.get('sessionQuery') as SessionQueryLike | undefined
175
+ if (query?.readTitleSnapshots === undefined) return
176
+ const observed = await query.readTitleSnapshots(cold.map(header => String(header.id))).catch(() => [])
177
+ const titleById = new Map<string, { title: string; promptAt: number }>()
178
+ for (const entry of observed) {
179
+ if (entry.status !== 'fulfilled') continue
180
+ const snapshot = entry.value?.title
181
+ if (snapshot?.title === undefined || snapshot.title === '') continue
182
+ titleById.set(String(entry.sessionId), { title: snapshot.title, promptAt: snapshot.updatedAt ?? 0 })
183
+ }
271
184
  for (const header of cold) {
272
185
  if (archived.has(String(header.id))) continue
273
- const cached = sessionMetaCache.get(String(header.id))
274
- if (cached !== undefined && cached.blank !== undefined) {
275
- if (!cached.blank) {
276
- summaries.push(toSummary(String(header.id), cached.title, header.cwd, header.createdAt, cached.promptAt, workspacePaths))
277
- }
278
- continue
279
- }
280
- pending.push(header)
281
- }
282
- for (let index = 0; index < pending.length; index += PROBE_BATCH_SIZE) {
283
- await Promise.all(pending.slice(index, index + PROBE_BATCH_SIZE).map(async header => {
284
- const meta = await resolveColdMeta(cache, persistence, header)
285
- cacheSessionMeta(String(header.id), meta)
286
- if (!meta.blank) {
287
- summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths))
288
- }
289
- }))
186
+ // No title snapshot means the session never ran a turn: the host's
187
+ // deterministic fallback titles every session with a user message.
188
+ const meta = titleById.get(String(header.id))
189
+ if (meta === undefined) continue
190
+ summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths))
290
191
  }
291
192
  }
292
193
 
@@ -299,17 +200,6 @@ function fallbackName(id: string, directory?: string): string {
299
200
  return match?.[0] ?? id
300
201
  }
301
202
 
302
- function titleFromEvents(events: readonly SessionEvent[]): string | undefined {
303
- let title: string | undefined
304
- for (const event of events) {
305
- if ((event as { type?: string }).type === 'session/title') {
306
- const data = (event as { data?: { title?: string } }).data
307
- if (data?.title !== undefined && data.title !== '') title = data.title
308
- }
309
- }
310
- return title
311
- }
312
-
313
203
  function firstUserText(events: readonly SessionEvent[]): string | undefined {
314
204
  for (const event of events) {
315
205
  if (event.type !== 'user/message') continue
@@ -1,3 +1,6 @@
1
+
2
+ import { writeStdoutRaw } from './mouse.ts'
3
+
1
4
  const SEQUENCES = {
2
5
  beam: '\x1b[1 q',
3
6
  block: '\x1b[2 q',
@@ -9,5 +12,5 @@ const SEQUENCES = {
9
12
  export type CursorShape = keyof typeof SEQUENCES
10
13
 
11
14
  export function writeCursorShape(shape: CursorShape): void {
12
- process.stdout.write(SEQUENCES[shape])
15
+ writeStdoutRaw(SEQUENCES[shape])
13
16
  }
@@ -1,3 +1,5 @@
1
+ import { writeSync } from 'node:fs'
2
+
1
3
  export type MouseEventType = 'down' | 'up' | 'drag' | 'move' | 'scroll'
2
4
 
3
5
  export interface MouseEventData {
@@ -12,12 +14,27 @@ export interface MouseEventData {
12
14
  const MOUSE_ENABLE_SEQUENCES = '\x1b[?1003l\x1b[?1000h\x1b[?1002h\x1b[?1006h'
13
15
  const MOUSE_DISABLE_SEQUENCES = '\x1b[?1003l\x1b[?1000l\x1b[?1002l\x1b[?1006l'
14
16
 
17
+ /**
18
+ * Terminal-mode sequences must reach the device even when the process exits
19
+ * right after: on Windows, TTY writes through process.stdout are
20
+ * asynchronous and a trailing restore sequence is silently dropped, leaving
21
+ * the terminal in mouse-tracking mode. fd 1 writes are synchronous.
22
+ */
23
+ export function writeStdoutRaw(text: string): void {
24
+ try {
25
+ writeSync(1, text)
26
+ } catch {
27
+ // A full stdout buffer (EAGAIN) can only be retried asynchronously.
28
+ process.stdout.write(text)
29
+ }
30
+ }
31
+
15
32
  export function writeMouseEnable(): void {
16
- process.stdout.write(MOUSE_ENABLE_SEQUENCES)
33
+ writeStdoutRaw(MOUSE_ENABLE_SEQUENCES)
17
34
  }
18
35
 
19
36
  export function writeMouseDisable(): void {
20
- process.stdout.write(MOUSE_DISABLE_SEQUENCES)
37
+ writeStdoutRaw(MOUSE_DISABLE_SEQUENCES)
21
38
  }
22
39
 
23
40
  interface ParsedSequence {
package/src/ui/app.tsx CHANGED
@@ -9,6 +9,8 @@ import type { ChatBridge } from '../chat/bridge.ts'
9
9
  import type { AgentActivity } from '../chat/store.ts'
10
10
  import { rowIndexFor, selectionText } from './message/layout.ts'
11
11
  import { glyphs } from '../terminal/glyphs.ts'
12
+ import { writeMouseDisable } from '../terminal/mouse.ts'
13
+ import { writeCursorShape } from '../terminal/cursor-shape.ts'
12
14
  import { chromeSelectionText } from './selection-registry.ts'
13
15
  import type { ScreenCapture } from '../terminal/screen.ts'
14
16
  import type { LineSelection } from '../model/selection.ts'
@@ -332,6 +334,10 @@ export function App({ bridge, screen, themeTick = 0 }: AppProps) {
332
334
  }
333
335
  if (!exiting.current) {
334
336
  exiting.current = true
337
+ // Restore terminal modes synchronously before signaling: on Windows
338
+ // the queued async writes of the dispose path can be lost at exit.
339
+ writeCursorShape('reset')
340
+ writeMouseDisable()
335
341
  process.kill(process.pid, 'SIGINT')
336
342
  setTimeout(() => process.exit(0), FORCE_EXIT_DELAY_MS).unref()
337
343
  }