dsh-code 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { readdir, writeFile } from "node:fs/promises";
4
- import { basename, join } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
6
6
  import z from "@deepseek-ai/schemastery";
7
7
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
@@ -879,14 +879,24 @@ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u;
879
879
  /** Render markdown text into styled lines of at most `width` columns. */
880
880
  function renderMarkdown(text, width) {
881
881
  const lines = [];
882
+ let separatorPending = false;
882
883
  const push = (segments) => {
883
884
  for (const wrapped of wrapSegments(segments, Math.max(10, width))) lines.push({ segments: merge(wrapped) });
884
885
  };
886
+ const startBlock = () => {
887
+ if (separatorPending && lines.length > 0 && lines.at(-1)?.segments.length !== 0) lines.push({ segments: [] });
888
+ separatorPending = false;
889
+ };
885
890
  const source = text.replaceAll("\r", "").split("\n");
886
891
  let index = 0;
887
892
  while (index < source.length) {
888
893
  const line = source[index] ?? "";
889
894
  index += 1;
895
+ if (line.trim() === "") {
896
+ separatorPending = lines.length > 0;
897
+ continue;
898
+ }
899
+ startBlock();
890
900
  const fence = FENCE.exec(line);
891
901
  if (fence !== null) {
892
902
  const language = fence[1] ?? "";
@@ -898,7 +908,6 @@ function renderMarkdown(text, width) {
898
908
  index += 1;
899
909
  continue;
900
910
  }
901
- if (line.trim() === "") continue;
902
911
  if (RULE.test(line.trim())) {
903
912
  push([seg(` ${"─".repeat(Math.max(1, Math.floor(width / 4)))}`, "dim")]);
904
913
  continue;
@@ -971,6 +980,64 @@ function caretVisible(tick) {
971
980
  return tick % 2 === 0;
972
981
  }
973
982
  //#endregion
983
+ //#region src/render/inspector.ts
984
+ /** Composer/status chrome plus one optional, fixed-height local notice row. */
985
+ const INSPECTOR_CHROME_ROWS = 5;
986
+ /** One transcript-to-composer gutter, collapsed on short terminals. */
987
+ function layoutGutterRows(rows) {
988
+ return Math.max(1, Math.floor(rows)) >= 14 ? 1 : 0;
989
+ }
990
+ /**
991
+ * Keep the inspector plus its persistent status/composer chrome below
992
+ * `stdout.rows`: at equality Ink clears the terminal and rewrites all
993
+ * accumulated `<Static>` output on every frame.
994
+ */
995
+ function panelViewport(columns, rows) {
996
+ const safeColumns = Math.max(1, Math.floor(columns));
997
+ const safeRows = Math.max(1, Math.floor(rows));
998
+ const maxHeight = Math.max(0, Math.min(safeRows - 2 - INSPECTOR_CHROME_ROWS - layoutGutterRows(safeRows), Math.floor(safeRows / 2)));
999
+ const compact = maxHeight < 5 || safeColumns < 8;
1000
+ const gapRows = !compact && maxHeight >= 7 ? 2 : 0;
1001
+ return {
1002
+ maxHeight,
1003
+ bodyRows: compact ? 0 : maxHeight - 4 - gapRows,
1004
+ gapRows,
1005
+ contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
1006
+ compact
1007
+ };
1008
+ }
1009
+ /** Backward-compatible name for the Ctrl+O-specific caller and tests. */
1010
+ function inspectorViewport(columns, rows) {
1011
+ return panelViewport(columns, rows);
1012
+ }
1013
+ /** Clamp a first-visible row to the range representable by one viewport. */
1014
+ function clampScroll(offset, totalRows, visibleRows) {
1015
+ const last = Math.max(0, Math.max(0, Math.floor(totalRows)) - Math.max(0, Math.floor(visibleRows)));
1016
+ return Math.max(0, Math.min(Math.floor(offset), last));
1017
+ }
1018
+ /** Move a viewport by a signed row delta without escaping its content. */
1019
+ function moveScroll(offset, delta, totalRows, visibleRows) {
1020
+ return clampScroll(offset + delta, totalRows, visibleRows);
1021
+ }
1022
+ /** Keep one focused row visible while preserving the current window when possible. */
1023
+ function revealRow(offset, row, totalRows, visibleRows) {
1024
+ const size = Math.max(1, Math.floor(visibleRows));
1025
+ const target = Math.max(0, Math.min(Math.floor(row), Math.max(0, totalRows - 1)));
1026
+ if (target < offset) return clampScroll(target, totalRows, size);
1027
+ if (target >= offset + size) return clampScroll(target - size + 1, totalRows, size);
1028
+ return clampScroll(offset, totalRows, size);
1029
+ }
1030
+ /** Center a selected list row where possible, clamped at both ends. */
1031
+ function selectionWindow(cursor, totalRows, visibleRows) {
1032
+ return clampScroll(cursor - Math.floor(Math.max(1, visibleRows) / 2), totalRows, visibleRows);
1033
+ }
1034
+ /** Follow appended history only while the inspector cursor was at the tail. */
1035
+ function followInspectorCursor(cursor, previousLength, nextLength) {
1036
+ const nextLast = Math.max(0, nextLength - 1);
1037
+ if (cursor >= Math.max(0, previousLength - 1)) return nextLast;
1038
+ return Math.min(cursor, nextLast);
1039
+ }
1040
+ //#endregion
974
1041
  //#region src/render/status.ts
975
1042
  /**
976
1043
  * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
@@ -1029,6 +1096,7 @@ function buildStatusGroups(facts, stats) {
1029
1096
  facts.plan ? "⧉ plan" : void 0
1030
1097
  ].filter((part) => part !== void 0 && part !== "");
1031
1098
  if (identity.length > 0) groups.push(identity.join(" · "));
1099
+ if (facts.mode !== void 0 && facts.mode !== "") groups.push(`mode ${facts.mode}`);
1032
1100
  if (stats.turns > 0 || stats.steps > 0) {
1033
1101
  groups.push(`T${stats.turns} · S${stats.steps}`);
1034
1102
  const durations = [];
@@ -1076,12 +1144,37 @@ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
1076
1144
  function displayText(text) {
1077
1145
  return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
1078
1146
  }
1147
+ /** Collapse external text to one terminal-safe logical row. */
1148
+ function singleLineText(text) {
1149
+ return displayText(text).replace(/\r?\n/gu, " ↵ ").replace(/\t/gu, " ");
1150
+ }
1079
1151
  /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
1080
1152
  function cellWidth(text) {
1081
1153
  let columns = 0;
1082
1154
  for (const char of text) columns += (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
1083
1155
  return columns;
1084
1156
  }
1157
+ /**
1158
+ * Truncate one display-safe row without ever exceeding its physical-column
1159
+ * budget. The ellipsis is included inside the budget, matching Codex's popup
1160
+ * truncation contract; the previous app-local helper appended it after the
1161
+ * row was already full and could force an extra terminal wrap.
1162
+ */
1163
+ function truncateColumns(text, columns) {
1164
+ const limit = Math.max(0, Math.floor(columns));
1165
+ if (limit === 0) return "";
1166
+ if (cellWidth(text) <= limit) return text;
1167
+ const contentLimit = limit - 1;
1168
+ let used = 0;
1169
+ let result = "";
1170
+ for (const char of text) {
1171
+ const width = cellWidth(char);
1172
+ if (used + width > contentLimit) break;
1173
+ result += char;
1174
+ used += width;
1175
+ }
1176
+ return `${result}…`;
1177
+ }
1085
1178
  /** Read one Unicode character immediately before `end`. */
1086
1179
  function previousCharacter(text, end) {
1087
1180
  const last = text.charCodeAt(end - 1);
@@ -1145,58 +1238,6 @@ function displayTail(text, columns, rows) {
1145
1238
  };
1146
1239
  }
1147
1240
  //#endregion
1148
- //#region src/render/inspector.ts
1149
- /** The three-row read-only composer frame plus its one-row status footer. */
1150
- const INSPECTOR_CHROME_ROWS = 4;
1151
- /**
1152
- * Keep the inspector plus its persistent status/composer chrome below
1153
- * `stdout.rows`: at equality Ink clears the terminal and rewrites all
1154
- * accumulated `<Static>` output on every frame.
1155
- */
1156
- function panelViewport(columns, rows) {
1157
- const safeColumns = Math.max(1, Math.floor(columns));
1158
- const safeRows = Math.max(1, Math.floor(rows));
1159
- const maxHeight = Math.max(0, Math.min(safeRows - 2 - INSPECTOR_CHROME_ROWS, Math.floor(safeRows / 2)));
1160
- const compact = maxHeight < 5 || safeColumns < 8;
1161
- return {
1162
- maxHeight,
1163
- bodyRows: compact ? 0 : maxHeight - 4,
1164
- contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
1165
- compact
1166
- };
1167
- }
1168
- /** Backward-compatible name for the Ctrl+O-specific caller and tests. */
1169
- function inspectorViewport(columns, rows) {
1170
- return panelViewport(columns, rows);
1171
- }
1172
- /** Clamp a first-visible row to the range representable by one viewport. */
1173
- function clampScroll(offset, totalRows, visibleRows) {
1174
- const last = Math.max(0, Math.max(0, Math.floor(totalRows)) - Math.max(0, Math.floor(visibleRows)));
1175
- return Math.max(0, Math.min(Math.floor(offset), last));
1176
- }
1177
- /** Move a viewport by a signed row delta without escaping its content. */
1178
- function moveScroll(offset, delta, totalRows, visibleRows) {
1179
- return clampScroll(offset + delta, totalRows, visibleRows);
1180
- }
1181
- /** Keep one focused row visible while preserving the current window when possible. */
1182
- function revealRow(offset, row, totalRows, visibleRows) {
1183
- const size = Math.max(1, Math.floor(visibleRows));
1184
- const target = Math.max(0, Math.min(Math.floor(row), Math.max(0, totalRows - 1)));
1185
- if (target < offset) return clampScroll(target, totalRows, size);
1186
- if (target >= offset + size) return clampScroll(target - size + 1, totalRows, size);
1187
- return clampScroll(offset, totalRows, size);
1188
- }
1189
- /** Center a selected list row where possible, clamped at both ends. */
1190
- function selectionWindow(cursor, totalRows, visibleRows) {
1191
- return clampScroll(cursor - Math.floor(Math.max(1, visibleRows) / 2), totalRows, visibleRows);
1192
- }
1193
- /** Follow appended history only while the inspector cursor was at the tail. */
1194
- function followInspectorCursor(cursor, previousLength, nextLength) {
1195
- const nextLast = Math.max(0, nextLength - 1);
1196
- if (cursor >= Math.max(0, previousLength - 1)) return nextLast;
1197
- return Math.min(cursor, nextLast);
1198
- }
1199
- //#endregion
1200
1241
  //#region src/render/lines.ts
1201
1242
  /** Construct one segment without leaking mutable objects into cached rows. */
1202
1243
  function lineSegment(text, style = "plain") {
@@ -1315,6 +1356,291 @@ function transcriptEntryLines(entry, columns) {
1315
1356
  }
1316
1357
  }
1317
1358
  //#endregion
1359
+ //#region src/kernel-panels.ts
1360
+ /** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
1361
+ function color(rgb) {
1362
+ return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
1363
+ }
1364
+ function ListFrame(props) {
1365
+ const stdout = useStdout().stdout;
1366
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1367
+ if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
1368
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns(`${props.title} · esc close`, viewport.contentColumns));
1369
+ const stateRows = props.loading ? [{
1370
+ key: "loading",
1371
+ text: " loading…"
1372
+ }] : props.error !== void 0 ? [{
1373
+ key: "error",
1374
+ text: ` ${singleLineText(props.error)}`
1375
+ }] : props.rows.length === 0 ? [{
1376
+ key: "empty",
1377
+ text: " no matching entries"
1378
+ }] : props.rows;
1379
+ const bodyRows = Math.max(1, viewport.bodyRows - 1);
1380
+ const offset = revealRow(0, props.cursor, stateRows.length, bodyRows);
1381
+ const visible = stateRows.slice(offset, offset + bodyRows);
1382
+ return createElement(Box, {
1383
+ borderStyle: "round",
1384
+ borderColor: color(TUI_RGB.dim),
1385
+ flexDirection: "column",
1386
+ paddingX: 1
1387
+ }, createElement(Text, {
1388
+ color: color(TUI_RGB.brandBright),
1389
+ wrap: "truncate-end"
1390
+ }, truncateColumns(props.title, viewport.contentColumns)), createElement(Text, {
1391
+ dimColor: true,
1392
+ wrap: "truncate-end"
1393
+ }, truncateColumns(`search: ${props.query === "" ? "type to filter" : props.query}`, viewport.contentColumns)), ...visible.map((row, index) => {
1394
+ const absolute = offset + index;
1395
+ const selected = !props.loading && props.error === void 0 && props.rows.length > 0 && absolute === props.cursor;
1396
+ return createElement(Text, {
1397
+ key: row.key,
1398
+ color: selected ? color(TUI_RGB.brandBright) : row.disabled ? color(TUI_RGB.dim) : void 0,
1399
+ dimColor: row.disabled,
1400
+ wrap: "truncate-end"
1401
+ }, truncateColumns(`${selected ? "› " : " "}${row.text}`, viewport.contentColumns));
1402
+ }), createElement(Text, {
1403
+ dimColor: true,
1404
+ wrap: "truncate-end"
1405
+ }, truncateColumns(props.footer, viewport.contentColumns)));
1406
+ }
1407
+ function editQuery(query, input, key) {
1408
+ if (key.backspace || key.delete) return query.slice(0, -1);
1409
+ if (input.length === 1 && input >= " " && input !== "") return query + input;
1410
+ }
1411
+ function ModePanel({ current, load, select, close }) {
1412
+ const [rows, setRows] = useState([]);
1413
+ const [query, setQuery] = useState("");
1414
+ const [cursor, setCursor] = useState(0);
1415
+ const [loading, setLoading] = useState(true);
1416
+ const [error, setError] = useState();
1417
+ const refresh = () => {
1418
+ setLoading(true);
1419
+ setError(void 0);
1420
+ Promise.resolve().then(load).then((value) => {
1421
+ setRows(value);
1422
+ setLoading(false);
1423
+ }, (reason) => {
1424
+ setError(reason instanceof Error ? reason.message : String(reason));
1425
+ setLoading(false);
1426
+ });
1427
+ };
1428
+ useEffect(refresh, []);
1429
+ const visible = useMemo(() => rows.filter((row) => `${row.id} ${row.name ?? ""} ${row.description ?? ""}`.toLowerCase().includes(query.toLowerCase())), [rows, query]);
1430
+ useEffect(() => setCursor((value) => Math.min(value, Math.max(0, visible.length - 1))), [visible.length]);
1431
+ useInput((input, key) => {
1432
+ if (key.escape || input === "q") return close();
1433
+ if (input === "r" && query === "") return refresh();
1434
+ if (key.upArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length);
1435
+ if (key.downArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + 1) % visible.length);
1436
+ if (key.return && visible[cursor]?.broken === void 0) return select(visible[cursor].id);
1437
+ const next = editQuery(query, input, key);
1438
+ if (next !== void 0) {
1439
+ setQuery(next);
1440
+ setCursor(0);
1441
+ }
1442
+ });
1443
+ return createElement(ListFrame, {
1444
+ title: `/mode · current ${current}`,
1445
+ rows: visible.map((row) => ({
1446
+ key: row.id,
1447
+ disabled: row.broken !== void 0,
1448
+ text: `${row.id === current ? "●" : "○"} ${row.name ?? row.id} · ${row.description ?? row.trust}${row.broken === void 0 ? "" : ` · broken: ${row.broken}`}`
1449
+ })),
1450
+ cursor,
1451
+ loading,
1452
+ error,
1453
+ query,
1454
+ footer: "↑↓ choose · enter switch · r refresh · esc close"
1455
+ });
1456
+ }
1457
+ function PluginPanel({ load, close, initialQuery = "" }) {
1458
+ const [epoch, setEpoch] = useState(0);
1459
+ const [query, setQuery] = useState(initialQuery);
1460
+ const [cursor, setCursor] = useState(0);
1461
+ const [expanded, setExpanded] = useState(false);
1462
+ const rows = useMemo(() => load().filter((row) => `${row.entryId} ${row.moduleName} ${row.phase ?? ""}`.toLowerCase().includes(query.toLowerCase())), [epoch, query]);
1463
+ useEffect(() => setCursor((value) => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
1464
+ useInput((input, key) => {
1465
+ if (key.escape || input === "q") return close();
1466
+ if (input === "r" && query === "") return setEpoch((value) => value + 1);
1467
+ if (key.upArrow) return setCursor((value) => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length);
1468
+ if (key.downArrow) return setCursor((value) => rows.length === 0 ? 0 : (value + 1) % rows.length);
1469
+ if (key.return) return setExpanded((value) => !value);
1470
+ const next = editQuery(query, input, key);
1471
+ if (next !== void 0) {
1472
+ setQuery(next);
1473
+ setCursor(0);
1474
+ }
1475
+ });
1476
+ return createElement(ListFrame, {
1477
+ title: "/plugin · loader inspector",
1478
+ rows: rows.map((row, index) => ({
1479
+ key: row.entryId,
1480
+ disabled: !row.enabled,
1481
+ text: `${row.enabled ? "●" : "○"} ${row.entryId} · ${row.phase ?? "not mounted"}${expanded && index === cursor ? ` · ${row.moduleName}` : ""}`
1482
+ })),
1483
+ cursor,
1484
+ loading: false,
1485
+ query,
1486
+ footer: "↑↓ inspect · enter details · r refresh · esc close"
1487
+ });
1488
+ }
1489
+ function ResumePanel({ currentCwd, load, readTranscript, select, close }) {
1490
+ const [options, setOptions] = useState({
1491
+ sessions: "roots",
1492
+ cwd: "all",
1493
+ sort: "newest",
1494
+ currentCwd,
1495
+ query: ""
1496
+ });
1497
+ const [focus, setFocus] = useState(0);
1498
+ const [density, setDensity] = useState("comfortable");
1499
+ const [rows, setRows] = useState([]);
1500
+ const [cursor, setCursor] = useState(0);
1501
+ const [loading, setLoading] = useState(true);
1502
+ const [error, setError] = useState();
1503
+ const [expanded, setExpanded] = useState();
1504
+ const [transcript, setTranscript] = useState();
1505
+ const transcriptLoad = useRef();
1506
+ useEffect(() => () => transcriptLoad.current?.abort(), []);
1507
+ useEffect(() => {
1508
+ const controller = new AbortController();
1509
+ setLoading(true);
1510
+ setError(void 0);
1511
+ Promise.resolve().then(() => load(options, controller.signal)).then((value) => {
1512
+ if (!controller.signal.aborted) {
1513
+ setRows(value);
1514
+ setLoading(false);
1515
+ }
1516
+ }, (reason) => {
1517
+ if (!controller.signal.aborted) {
1518
+ setError(reason instanceof Error ? reason.message : String(reason));
1519
+ setLoading(false);
1520
+ }
1521
+ });
1522
+ return () => controller.abort();
1523
+ }, [options]);
1524
+ useEffect(() => setCursor((value) => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
1525
+ const cycle = () => {
1526
+ if (focus === 3) {
1527
+ setDensity((current) => current === "comfortable" ? "dense" : "comfortable");
1528
+ return;
1529
+ }
1530
+ setOptions((value) => {
1531
+ if (focus === 0) return {
1532
+ ...value,
1533
+ sessions: value.sessions === "roots" ? "all" : "roots"
1534
+ };
1535
+ if (focus === 1) return {
1536
+ ...value,
1537
+ cwd: value.cwd === "all" ? "current" : "all"
1538
+ };
1539
+ return {
1540
+ ...value,
1541
+ sort: value.sort === "newest" ? "oldest" : "newest"
1542
+ };
1543
+ });
1544
+ };
1545
+ useInput((input, key) => {
1546
+ if (key.escape || input === "q") return close();
1547
+ if (key.tab) return setFocus((value) => (value + (key.shift ? 3 : 1)) % 4);
1548
+ if (key.leftArrow) return cycle();
1549
+ if (key.rightArrow) return cycle();
1550
+ if (key.upArrow) return setCursor((value) => rows.length === 0 ? 0 : Math.max(0, value - 1));
1551
+ if (key.downArrow) return setCursor((value) => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1));
1552
+ if (key.pageUp) return setCursor((value) => Math.max(0, value - 8));
1553
+ if (key.pageDown) return setCursor((value) => Math.min(rows.length - 1, value + 8));
1554
+ if (input === "g") return setCursor(0);
1555
+ if (input === "G") return setCursor(Math.max(0, rows.length - 1));
1556
+ if (input === "d") return setDensity((value) => value === "comfortable" ? "dense" : "comfortable");
1557
+ if (input === "e" && rows[cursor] !== void 0) return setExpanded((value) => value === rows[cursor].id ? void 0 : rows[cursor].id);
1558
+ if (input === "t" && rows[cursor] !== void 0) {
1559
+ const row = rows[cursor];
1560
+ transcriptLoad.current?.abort();
1561
+ setTranscript({ id: row.id });
1562
+ const controller = new AbortController();
1563
+ transcriptLoad.current = controller;
1564
+ Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then((text) => {
1565
+ if (!controller.signal.aborted) setTranscript({
1566
+ id: row.id,
1567
+ text
1568
+ });
1569
+ }, (reason) => {
1570
+ if (!controller.signal.aborted) setTranscript({
1571
+ id: row.id,
1572
+ error: reason instanceof Error ? reason.message : String(reason)
1573
+ });
1574
+ });
1575
+ return;
1576
+ }
1577
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]);
1578
+ const next = editQuery(options.query, input, key);
1579
+ if (next !== void 0) {
1580
+ setOptions((value) => ({
1581
+ ...value,
1582
+ query: next
1583
+ }));
1584
+ setCursor(0);
1585
+ }
1586
+ }, { isActive: transcript === void 0 });
1587
+ if (transcript !== void 0) return createElement(DocumentPanel, {
1588
+ title: `transcript · ${transcript.id}`,
1589
+ text: transcript.text,
1590
+ error: transcript.error,
1591
+ close: () => {
1592
+ transcriptLoad.current?.abort();
1593
+ setTranscript(void 0);
1594
+ }
1595
+ });
1596
+ const toolbar = `[${focus === 0 ? ">" : ""}${options.sessions}] [${focus === 1 ? ">" : ""}${options.cwd} cwd] [${focus === 2 ? ">" : ""}${options.sort}] [${focus === 3 ? ">" : ""}${density}]`;
1597
+ return createElement(ListFrame, {
1598
+ title: `/resume · ${toolbar}`,
1599
+ rows: rows.map((row) => ({
1600
+ key: row.id,
1601
+ disabled: !row.resumable,
1602
+ text: `${row.subagent ? "↳" : "○"} ${row.title ?? row.id.slice(-12)}${density === "comfortable" ? ` · ${row.workspace} · ${row.preset}` : ""}${row.live ? " · live" : ""}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === void 0 ? "" : ` · parent ${row.parent}`}` : ""}`
1603
+ })),
1604
+ cursor,
1605
+ loading,
1606
+ error,
1607
+ query: options.query,
1608
+ footer: "type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume"
1609
+ });
1610
+ }
1611
+ function DocumentPanel({ title, text, error, close }) {
1612
+ const stdout = useStdout().stdout;
1613
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1614
+ const [scroll, setScroll] = useState(0);
1615
+ const lines = useMemo(() => text === void 0 ? [] : textLines(text, viewport.contentColumns).map((line) => line.segments.map((segment) => segment.text).join("")), [text, viewport.contentColumns]);
1616
+ useInput((input, key) => {
1617
+ if (key.escape || input === "q" || input === "t") return close();
1618
+ if (key.upArrow) return setScroll((value) => Math.max(0, value - 1));
1619
+ if (key.downArrow) return setScroll((value) => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1));
1620
+ if (key.pageUp) return setScroll((value) => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)));
1621
+ if (key.pageDown) return setScroll((value) => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)));
1622
+ if (input === "g") return setScroll(0);
1623
+ if (input === "G") return setScroll(Math.max(0, lines.length - viewport.bodyRows));
1624
+ });
1625
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("transcript · esc close", viewport.contentColumns));
1626
+ const body = error !== void 0 ? [`error: ${singleLineText(error)}`] : text === void 0 ? ["loading transcript…"] : lines.slice(scroll, scroll + viewport.bodyRows);
1627
+ return createElement(Box, {
1628
+ borderStyle: "round",
1629
+ borderColor: color(TUI_RGB.dim),
1630
+ flexDirection: "column",
1631
+ paddingX: 1
1632
+ }, createElement(Text, {
1633
+ color: color(TUI_RGB.brandBright),
1634
+ wrap: "truncate-end"
1635
+ }, truncateColumns(title, viewport.contentColumns)), ...body.map((line, index) => createElement(Text, {
1636
+ key: `${scroll}-${index}`,
1637
+ wrap: "truncate-end"
1638
+ }, truncateColumns(line, viewport.contentColumns))), createElement(Text, {
1639
+ dimColor: true,
1640
+ wrap: "truncate-end"
1641
+ }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)));
1642
+ }
1643
+ //#endregion
1318
1644
  //#region src/app.ts
1319
1645
  /**
1320
1646
  * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
@@ -1339,21 +1665,10 @@ const RESIZE_REFLOW_CLEAR = "\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H";
1339
1665
  function inkColor(triple) {
1340
1666
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`;
1341
1667
  }
1342
- /** Truncate text to a visible-column budget, appending … when cut. */
1343
- function truncateColumns(text, max) {
1344
- let columns = 0;
1345
- let out = "";
1346
- for (const char of text) {
1347
- const width = (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
1348
- if (columns + width > max) return `${out}…`;
1349
- out += char;
1350
- columns += width;
1351
- }
1352
- return out;
1353
- }
1354
1668
  /** Pad text with spaces to a visible-column target (menu name column). */
1355
1669
  function padColumns(text, width) {
1356
- return text + " ".repeat(Math.max(0, width - visibleColumns(text)));
1670
+ const clipped = truncateColumns(singleLineText(text), width);
1671
+ return clipped + " ".repeat(Math.max(0, width - visibleColumns(clipped)));
1357
1672
  }
1358
1673
  /** Interval-driven frame counter for one self-contained animated leaf. */
1359
1674
  function useFrames(intervalMs) {
@@ -1521,11 +1836,15 @@ function StyledRows({ lines }) {
1521
1836
  ...lineStyleProps(segment.style)
1522
1837
  }, segment.text)))));
1523
1838
  }
1839
+ /** Codex-style panel rhythm that still participates in the row budget. */
1840
+ function PanelGap({ visible }) {
1841
+ return visible ? createElement(Text, null, " ") : void 0;
1842
+ }
1524
1843
  /** One settled markdown document rendered as styled lines at the terminal width. */
1525
1844
  function MarkdownBody({ text }) {
1526
1845
  const columns = useStdout().stdout?.columns ?? 80;
1527
1846
  const lines = useMemo(() => renderMarkdown(displayText(text), Math.max(20, columns - 2)), [text, columns]);
1528
- return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, { key: index }, ...line.segments.map((segment, at) => createElement(Text, {
1847
+ return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, { key: index }, line.segments.length === 0 ? " " : line.segments.map((segment, at) => createElement(Text, {
1529
1848
  key: at,
1530
1849
  ...segmentProps(segment.style)
1531
1850
  }, segment.text)))));
@@ -1691,9 +2010,21 @@ function StatusLine({ facts, stats, busy }) {
1691
2010
  wrap: "truncate-end"
1692
2011
  }, busy ? "● " : "○ ", groups.join(" | ")));
1693
2012
  }
2013
+ /**
2014
+ * One fixed-height local feedback row. Errors remain visible while a slash
2015
+ * subpage is open, but arbitrary exception text can never add physical rows
2016
+ * above the composer.
2017
+ */
2018
+ function NoticeLine({ text, tone, columns }) {
2019
+ const color = tone === "error" ? TUI_RGB.error : tone === "warning" ? TUI_RGB.warn : TUI_RGB.brandBright;
2020
+ const mark = tone === "error" ? "⨯" : tone === "warning" ? "!" : "•";
2021
+ return createElement(Box, { paddingLeft: 2 }, createElement(Text, {
2022
+ color: inkColor(color),
2023
+ wrap: "truncate-end"
2024
+ }, truncateColumns(`${mark} ${singleLineText(text)}`, Math.max(1, columns - 2))));
2025
+ }
1694
2026
  /** The y/n approval bar rendered while an approval ask is pending. */
1695
- function ApprovalBar({ approval, locked }) {
1696
- const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot);
2027
+ function ApprovalBar({ snapshot, locked }) {
1697
2028
  const stdout = useStdout().stdout;
1698
2029
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1699
2030
  const [scroll, setScroll] = useState(0);
@@ -1745,7 +2076,7 @@ function ApprovalBar({ approval, locked }) {
1745
2076
  color: inkColor(TUI_RGB.warn),
1746
2077
  bold: true,
1747
2078
  wrap: "truncate-end"
1748
- }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(Text, {
2079
+ }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, {
1749
2080
  dimColor: true,
1750
2081
  wrap: "truncate-end"
1751
2082
  }, dim(truncateColumns(answered ? "submitted…" : "↑↓/pgup/pgdn scroll · y allow once · n reject", viewport.contentColumns))));
@@ -1758,8 +2089,7 @@ function ApprovalBar({ approval, locked }) {
1758
2089
  * service with a `plan-review` intent — the approve option gets a ✓ mark,
1759
2090
  * the answer encoding stays identical.
1760
2091
  */
1761
- function QuestionBar({ store, locked }) {
1762
- const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
2092
+ function QuestionBar({ store, snapshot, locked }) {
1763
2093
  const stdout = useStdout().stdout;
1764
2094
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1765
2095
  const pending = snapshot.pending;
@@ -1960,13 +2290,13 @@ function QuestionBar({ store, locked }) {
1960
2290
  color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep),
1961
2291
  bold: true,
1962
2292
  wrap: "truncate-end"
1963
- }, truncateColumns(`${isPlan ? "📋 plan review" : "❓ question"} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns)), createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(Text, {
2293
+ }, truncateColumns(`${isPlan ? "📋 plan review" : "❓ question"} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, {
1964
2294
  dimColor: true,
1965
2295
  wrap: "truncate-end"
1966
2296
  }, dim(truncateColumns(footer, viewport.contentColumns))));
1967
2297
  }
1968
2298
  /** The /model panel: a scrolling list over the advisory model directory. */
1969
- function ModelPanel({ directory, error, onSelect, onClose }) {
2299
+ function ModelPanel({ directory, error, onSelect, onRetry, onClose }) {
1970
2300
  const [cursor, setCursor] = useState(0);
1971
2301
  const stdout = useStdout().stdout;
1972
2302
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
@@ -1983,6 +2313,10 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
1983
2313
  onClose();
1984
2314
  return;
1985
2315
  }
2316
+ if (input === "r") {
2317
+ onRetry();
2318
+ return;
2319
+ }
1986
2320
  if (rows.length === 0) return;
1987
2321
  if (key.upArrow) {
1988
2322
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
@@ -2011,9 +2345,27 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
2011
2345
  if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
2012
2346
  });
2013
2347
  if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
2014
- if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("/model · esc/q close", viewport.contentColumns));
2015
- const first = selectionWindow(cursor, rows.length, viewport.bodyRows);
2016
- const visible = rows.slice(first, first + viewport.bodyRows);
2348
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("/model · r retry · esc/q close", viewport.contentColumns));
2349
+ const stateRows = directory === void 0 && error === void 0 ? [createElement(Text, {
2350
+ key: "loading",
2351
+ dimColor: true,
2352
+ wrap: "truncate-end"
2353
+ }, " loading models…")] : error !== void 0 ? [createElement(Text, {
2354
+ key: "error",
2355
+ color: inkColor(TUI_RGB.error),
2356
+ wrap: "truncate-end"
2357
+ }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))] : [...directory?.failures.length === 0 ? [] : [createElement(Text, {
2358
+ key: "failures",
2359
+ color: inkColor(TUI_RGB.warn),
2360
+ wrap: "truncate-end"
2361
+ }, truncateColumns(` unavailable providers: ${directory?.failures.join(", ")}`, viewport.contentColumns))], ...rows.length === 0 ? [createElement(Text, {
2362
+ key: "empty",
2363
+ dimColor: true,
2364
+ wrap: "truncate-end"
2365
+ }, " no models available")] : []];
2366
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length);
2367
+ const first = selectionWindow(cursor, rows.length, rowBudget);
2368
+ const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget);
2017
2369
  return createElement(Box, {
2018
2370
  flexDirection: "column",
2019
2371
  paddingX: 1,
@@ -2023,13 +2375,7 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
2023
2375
  color: inkColor(TUI_RGB.brand),
2024
2376
  bold: true,
2025
2377
  wrap: "truncate-end"
2026
- }, truncateColumns(`/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), directory === void 0 && error === void 0 ? createElement(Text, {
2027
- dimColor: true,
2028
- wrap: "truncate-end"
2029
- }, " loading models…") : void 0, error !== void 0 ? createElement(Text, {
2030
- color: inkColor(TUI_RGB.error),
2031
- wrap: "truncate-end"
2032
- }, truncateColumns(` ${displayText(error)}`, viewport.contentColumns)) : void 0, ...visible.map((row) => {
2378
+ }, truncateColumns(`/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), ...stateRows, ...visible.map((row) => {
2033
2379
  const index = rows.indexOf(row);
2034
2380
  const label = displayText(`${row.providerName} · ${row.modelName}`);
2035
2381
  return createElement(Text, {
@@ -2037,22 +2383,22 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
2037
2383
  color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
2038
2384
  wrap: "truncate-end"
2039
2385
  }, truncateColumns(`${index === cursor ? "❯ " : " "}${label}`, viewport.contentColumns));
2040
- }), createElement(Text, {
2386
+ }), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, {
2041
2387
  dimColor: true,
2042
2388
  wrap: "truncate-end"
2043
- }, dim(truncateColumns("↑↓ move · pgup/pgdn page · g/G ends · enter select · esc/q close", viewport.contentColumns))));
2389
+ }, dim(truncateColumns("↑↓ move · pgup/pgdn page · g/G ends · enter select · r retry · esc/q close", viewport.contentColumns))));
2044
2390
  }
2045
2391
  /**
2046
2392
  * The /help overlay: one scrolling card with the keyboard map, the TUI-local
2047
2393
  * commands, the live registry commands, and the user-invocable skills — the
2048
2394
  * real command surface, replacing the one-line notice.
2049
2395
  */
2050
- function HelpPanel({ descriptors, skills, onClose }) {
2396
+ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }) {
2051
2397
  const stdout = useStdout().stdout;
2052
2398
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
2053
2399
  const [scroll, setScroll] = useState(0);
2054
- const nameWidth = 18;
2055
- const descBudget = Math.max(1, viewport.contentColumns - nameWidth - 2);
2400
+ const nameWidth = Math.min(18, Math.max(1, viewport.contentColumns - 2));
2401
+ const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2);
2056
2402
  const row = (label, description) => createElement(Text, {
2057
2403
  dimColor: true,
2058
2404
  wrap: "truncate-end"
@@ -2088,13 +2434,23 @@ function HelpPanel({ descriptors, skills, onClose }) {
2088
2434
  dimColor: true,
2089
2435
  wrap: "truncate-end"
2090
2436
  }, " ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends"),
2437
+ createElement(Text, { key: "commands-gap" }, " "),
2091
2438
  createElement(Text, {
2092
2439
  key: "commands-title",
2093
2440
  bold: true,
2094
2441
  wrap: "truncate-end"
2095
2442
  }, " commands"),
2443
+ ...commandError === void 0 ? [] : [createElement(Text, {
2444
+ key: "commands-error",
2445
+ color: inkColor(TUI_RGB.error),
2446
+ wrap: "truncate-end"
2447
+ }, truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns))],
2096
2448
  createElement(Box, { key: "local-help" }, row("/help", "show this overlay")),
2097
2449
  createElement(Box, { key: "local-model" }, row("/model", "switch the model")),
2450
+ createElement(Box, { key: "local-mode" }, row("/mode", "inspect or select the agent preset (/mode [preset])")),
2451
+ createElement(Box, { key: "local-new" }, row("/new", "create and switch to a fresh session (/new [preset])")),
2452
+ createElement(Box, { key: "local-resume" }, row("/resume", "browse or switch root sessions (/resume [id|prefix])")),
2453
+ createElement(Box, { key: "local-plugin" }, row("/plugin", "inspect the live plugin composition")),
2098
2454
  createElement(Box, { key: "local-clear" }, row("/clear", "clear the screen")),
2099
2455
  createElement(Box, { key: "local-export" }, row("/export", "export the transcript to markdown (/export [path])")),
2100
2456
  createElement(Box, { key: "local-title" }, row("/title", "rename this session (/title <text>)")),
@@ -2104,11 +2460,16 @@ function HelpPanel({ descriptors, skills, onClose }) {
2104
2460
  dimColor: true,
2105
2461
  wrap: "truncate-end"
2106
2462
  }, ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`)),
2107
- ...skills.length === 0 ? [] : [createElement(Text, {
2463
+ ...skills.length === 0 && skillError === void 0 ? [] : [createElement(Text, { key: "skills-gap" }, " "), createElement(Text, {
2108
2464
  key: "skills-title",
2109
2465
  bold: true,
2110
2466
  wrap: "truncate-end"
2111
2467
  }, " skills")],
2468
+ ...skillError === void 0 ? [] : [createElement(Text, {
2469
+ key: "skills-error",
2470
+ color: inkColor(TUI_RGB.error),
2471
+ wrap: "truncate-end"
2472
+ }, truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns))],
2112
2473
  ...skills.map((skill) => createElement(Text, {
2113
2474
  key: `skill-${skill.name}`,
2114
2475
  dimColor: true,
@@ -2145,7 +2506,7 @@ function HelpPanel({ descriptors, skills, onClose }) {
2145
2506
  color: inkColor(TUI_RGB.brand),
2146
2507
  bold: true,
2147
2508
  wrap: "truncate-end"
2148
- }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows), createElement(Text, {
2509
+ }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, {
2149
2510
  dimColor: true,
2150
2511
  wrap: "truncate-end"
2151
2512
  }, dim(truncateColumns("↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close", viewport.contentColumns))));
@@ -2269,7 +2630,7 @@ function VerbosePanel({ entries, onClose }) {
2269
2630
  color: inkColor(TUI_RGB.brand),
2270
2631
  bold: true,
2271
2632
  wrap: "truncate-end"
2272
- }, truncateColumns(title, viewport.contentColumns)), createElement(Box, { flexDirection: "column" }, entry === void 0 ? createElement(Text, { dimColor: true }, " no durable entries yet") : createElement(StyledRows, { lines: visible })), createElement(Text, {
2633
+ }, truncateColumns(title, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Box, { flexDirection: "column" }, entry === void 0 ? createElement(Text, { dimColor: true }, " no durable entries yet") : createElement(StyledRows, { lines: visible })), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, {
2273
2634
  dimColor: true,
2274
2635
  wrap: "truncate-end"
2275
2636
  }, dim(truncateColumns("←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close", viewport.contentColumns))));
@@ -2307,6 +2668,26 @@ function completionCandidates(value, descriptors, skills) {
2307
2668
  description: "switch the model",
2308
2669
  origin: "command"
2309
2670
  },
2671
+ {
2672
+ label: "/mode",
2673
+ description: "select the agent preset",
2674
+ origin: "command"
2675
+ },
2676
+ {
2677
+ label: "/new",
2678
+ description: "start a fresh session",
2679
+ origin: "command"
2680
+ },
2681
+ {
2682
+ label: "/resume",
2683
+ description: "browse or switch sessions",
2684
+ origin: "command"
2685
+ },
2686
+ {
2687
+ label: "/plugin",
2688
+ description: "inspect the plugin composition",
2689
+ origin: "command"
2690
+ },
2310
2691
  {
2311
2692
  label: "/clear",
2312
2693
  description: "clear the screen",
@@ -2362,16 +2743,19 @@ function CompletionMenu({ active, mention, index, rows }) {
2362
2743
  const columns = stdout?.columns ?? 80;
2363
2744
  const terminalRows = stdout?.rows ?? 30;
2364
2745
  if (!active) return void 0;
2365
- const nameWidth = Math.min(18, Math.max(0, ...rows.map((row) => visibleColumns(row.label))) + 2);
2366
- const descBudget = Math.max(24, columns - nameWidth - 8);
2746
+ const contentColumns = Math.max(1, columns - 4);
2747
+ const nameWidth = Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map((row) => visibleColumns(row.label))) + 2);
2748
+ const descBudget = Math.max(0, contentColumns - nameWidth - 2);
2367
2749
  const showFooter = terminalRows >= 12;
2368
- const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)));
2750
+ const verticalPadding = terminalRows >= 14 ? 1 : 0;
2751
+ const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10) - verticalPadding * 2));
2369
2752
  const selected = rows.length === 0 ? 0 : index % rows.length;
2370
2753
  const first = selectionWindow(selected, rows.length, limit);
2371
2754
  const visible = rows.slice(first, first + limit);
2372
2755
  return createElement(Box, {
2373
2756
  flexDirection: "column",
2374
- marginLeft: 2
2757
+ marginLeft: 2,
2758
+ paddingY: verticalPadding
2375
2759
  }, ...rows.length === 0 ? [createElement(Text, {
2376
2760
  key: "loading",
2377
2761
  dimColor: true
@@ -2393,7 +2777,7 @@ function CompletionMenu({ active, mention, index, rows }) {
2393
2777
  * While a modal (approval / question / model panel) owns the keys, the
2394
2778
  * box passes every key through untouched.
2395
2779
  */
2396
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }) {
2780
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, openMode, openResume, openPlugin, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }) {
2397
2781
  const columns = useStdout().stdout?.columns ?? 80;
2398
2782
  const [value, setValue] = useState("");
2399
2783
  const [cursor, setCursor] = useState(0);
@@ -2401,6 +2785,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2401
2785
  const historyIndex = useRef(null);
2402
2786
  const draft = useRef("");
2403
2787
  const [completionIndex, setCompletionIndex] = useState(0);
2788
+ const [dismissedMenuValue, setDismissedMenuValue] = useState(void 0);
2404
2789
  const candidates = completionCandidates(value, descriptors, skills);
2405
2790
  const slashActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
2406
2791
  const beforeCursor = value.slice(0, cursor);
@@ -2449,7 +2834,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2449
2834
  mentionActive,
2450
2835
  mentionToken?.query
2451
2836
  ]);
2452
- const menuActive = (slashActive || mentionActive || pathActive) && !busy;
2837
+ const menuActive = (slashActive || mentionActive || pathActive) && dismissedMenuValue !== value;
2453
2838
  const menuRows = mentionActive ? mentionRows.map((row) => ({
2454
2839
  label: row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`,
2455
2840
  description: row.description,
@@ -2462,8 +2847,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2462
2847
  useInput((input, key) => {
2463
2848
  if (!active) return;
2464
2849
  if (key.tab && key.shift) {
2465
- const next = cyclePermission();
2466
- if (next !== "") notify(`permission → ${next}`);
2850
+ try {
2851
+ const next = cyclePermission();
2852
+ if (next !== "") notify(`permission → ${next}`);
2853
+ } catch (error) {
2854
+ notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
2855
+ }
2467
2856
  return;
2468
2857
  }
2469
2858
  if (key.ctrl && input === "r") {
@@ -2480,15 +2869,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2480
2869
  setValue("");
2481
2870
  setCursor(0);
2482
2871
  setCompletionIndex(0);
2872
+ setDismissedMenuValue(void 0);
2483
2873
  } else quit();
2484
2874
  return;
2485
2875
  }
2486
2876
  if (key.ctrl && input === "d") {
2487
- if (busy) notify("cancel the running turn before exiting (Esc or Ctrl+C)");
2877
+ if (busy) notify("cancel the running turn before exiting (Esc or Ctrl+C)", "warning");
2488
2878
  else quit();
2489
2879
  return;
2490
2880
  }
2491
2881
  if (key.escape) {
2882
+ if (menuActive) {
2883
+ setDismissedMenuValue(value);
2884
+ return;
2885
+ }
2886
+ if (hasNotice) {
2887
+ dismissNotice();
2888
+ return;
2889
+ }
2492
2890
  if (busy) interrupt();
2493
2891
  return;
2494
2892
  }
@@ -2496,13 +2894,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2496
2894
  if (key.meta || key.ctrl && input === "j") {
2497
2895
  setValue(value.slice(0, cursor) + "\n" + value.slice(cursor));
2498
2896
  setCursor(cursor + 1);
2897
+ setDismissedMenuValue(void 0);
2499
2898
  return;
2500
2899
  }
2501
2900
  const text = value.trim();
2502
2901
  setValue("");
2503
2902
  setCursor(0);
2504
2903
  setCompletionIndex(0);
2904
+ setDismissedMenuValue(void 0);
2505
2905
  if (text === "") return;
2906
+ dismissNotice();
2506
2907
  history.current = [...history.current, text];
2507
2908
  historyIndex.current = null;
2508
2909
  if (text === "/quit") {
@@ -2516,6 +2917,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2516
2917
  if (text === "/clear") {
2517
2918
  refresh();
2518
2919
  clearView();
2920
+ dismissNotice();
2519
2921
  return;
2520
2922
  }
2521
2923
  if (text === "/export" || text.startsWith("/export ")) {
@@ -2523,13 +2925,36 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2523
2925
  return;
2524
2926
  }
2525
2927
  if (text === "/title" || text.startsWith("/title ")) {
2526
- notify(renameTitle(text.slice(7)));
2928
+ const outcome = renameTitle(text.slice(7));
2929
+ notify(outcome, outcome.startsWith("rename failed:") ? "error" : outcome.startsWith("usage:") || outcome.includes("unavailable") ? "warning" : "info");
2527
2930
  return;
2528
2931
  }
2529
2932
  if (text === "/model" || text.startsWith("/model ")) {
2530
2933
  openModel();
2531
2934
  return;
2532
2935
  }
2936
+ if (text === "/mode" || text.startsWith("/mode ")) {
2937
+ if (text.slice(5).trim() === "") openMode();
2938
+ else dispatch(text);
2939
+ return;
2940
+ }
2941
+ if (text === "/resume cancel") {
2942
+ notify(cancelSessionSwitch() ? "pending session switch cancelled" : "no pending session switch", "info");
2943
+ return;
2944
+ }
2945
+ if (text === "/resume" || text.startsWith("/resume ")) {
2946
+ if (text.slice(7).trim() === "") openResume();
2947
+ else dispatch(text);
2948
+ return;
2949
+ }
2950
+ if (text === "/new" || text.startsWith("/new ")) {
2951
+ createSession(text.slice(4).trim() || void 0);
2952
+ return;
2953
+ }
2954
+ if (text === "/plugin" || text.startsWith("/plugin ")) {
2955
+ openPlugin(text.slice(7).trim());
2956
+ return;
2957
+ }
2533
2958
  if (busy && !text.startsWith("/")) {
2534
2959
  steer(text);
2535
2960
  return;
@@ -2553,6 +2978,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2553
2978
  historyIndex.current = next;
2554
2979
  setValue(entries[next] ?? "");
2555
2980
  setCursor((entries[next] ?? "").length);
2981
+ setDismissedMenuValue(void 0);
2556
2982
  return;
2557
2983
  }
2558
2984
  if (key.downArrow) {
@@ -2563,11 +2989,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2563
2989
  historyIndex.current = null;
2564
2990
  setValue(draft.current);
2565
2991
  setCursor(draft.current.length);
2992
+ setDismissedMenuValue(void 0);
2566
2993
  return;
2567
2994
  }
2568
2995
  historyIndex.current = next;
2569
2996
  setValue(entries[next] ?? "");
2570
2997
  setCursor((entries[next] ?? "").length);
2998
+ setDismissedMenuValue(void 0);
2571
2999
  return;
2572
3000
  }
2573
3001
  if (key.tab && menuActive) {
@@ -2593,6 +3021,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2593
3021
  }
2594
3022
  }
2595
3023
  setCompletionIndex(0);
3024
+ setDismissedMenuValue(void 0);
2596
3025
  return;
2597
3026
  }
2598
3027
  if (key.backspace || key.delete) {
@@ -2600,6 +3029,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2600
3029
  setValue(value.slice(0, cursor - 1) + value.slice(cursor));
2601
3030
  setCursor(cursor - 1);
2602
3031
  setCompletionIndex(0);
3032
+ setDismissedMenuValue(void 0);
2603
3033
  }
2604
3034
  return;
2605
3035
  }
@@ -2614,10 +3044,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2614
3044
  if (key.ctrl && input === "u") {
2615
3045
  setValue("");
2616
3046
  setCursor(0);
3047
+ setDismissedMenuValue(void 0);
2617
3048
  return;
2618
3049
  }
2619
3050
  if (key.ctrl && input === "k") {
2620
3051
  setValue(value.slice(0, cursor));
3052
+ setDismissedMenuValue(void 0);
2621
3053
  return;
2622
3054
  }
2623
3055
  if (key.ctrl && input === "l") {
@@ -2636,6 +3068,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2636
3068
  setValue(value.slice(0, cursor) + input + value.slice(cursor));
2637
3069
  setCursor(cursor + input.length);
2638
3070
  setCompletionIndex(0);
3071
+ setDismissedMenuValue(void 0);
2639
3072
  }
2640
3073
  });
2641
3074
  if (frozen) {
@@ -2667,18 +3100,23 @@ function App(props) {
2667
3100
  const [modelOpen, setModelOpen] = useState(false);
2668
3101
  const [directory, setDirectory] = useState(void 0);
2669
3102
  const [modelError, setModelError] = useState(void 0);
2670
- const [notices, setNotices] = useState([]);
2671
- const notify = (text) => {
2672
- setNotices((current) => [...current, text]);
2673
- };
3103
+ const [modelLoadEpoch, setModelLoadEpoch] = useState(0);
3104
+ const [notice, setNotice] = useState(void 0);
3105
+ const notify = useCallback((text, tone = "info") => {
3106
+ setNotice({
3107
+ text,
3108
+ tone
3109
+ });
3110
+ }, []);
2674
3111
  useEffect(() => {
2675
3112
  props.onBridgeReady({ notify });
2676
3113
  }, []);
2677
3114
  useEffect(() => {
2678
- if (!modelOpen || directory !== void 0) return;
3115
+ if (!modelOpen) return;
2679
3116
  let cancelled = false;
3117
+ setDirectory(void 0);
2680
3118
  setModelError(void 0);
2681
- props.loadModels().then((loaded) => {
3119
+ Promise.resolve().then(() => props.loadModels()).then((loaded) => {
2682
3120
  if (!cancelled) setDirectory(loaded);
2683
3121
  }, (error) => {
2684
3122
  if (!cancelled) setModelError(error instanceof Error ? error.message : String(error));
@@ -2686,21 +3124,32 @@ function App(props) {
2686
3124
  return () => {
2687
3125
  cancelled = true;
2688
3126
  };
2689
- }, [modelOpen]);
3127
+ }, [
3128
+ modelOpen,
3129
+ modelLoadEpoch,
3130
+ props.loadModels
3131
+ ]);
2690
3132
  const busy = view.busy;
2691
3133
  const [showReasoning, setShowReasoning] = useState(false);
2692
3134
  const [verboseOpen, setVerboseOpen] = useState(false);
2693
3135
  const [helpOpen, setHelpOpen] = useState(false);
3136
+ const [modeOpen, setModeOpen] = useState(false);
3137
+ const [resumeOpen, setResumeOpen] = useState(false);
3138
+ const [pluginOpen, setPluginOpen] = useState(false);
3139
+ const [pluginQuery, setPluginQuery] = useState("");
2694
3140
  const [refreshEpoch, setRefreshEpoch] = useState(0);
2695
3141
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot);
2696
3142
  const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot);
2697
3143
  const approvalPending = approvalSnapshot.pending !== void 0;
2698
3144
  const questionPending = questionSnapshot.pending !== void 0;
2699
- const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending;
3145
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending;
2700
3146
  useEffect(() => {
2701
3147
  if (!approvalPending && !questionPending) return;
2702
3148
  setModelOpen(false);
2703
3149
  setHelpOpen(false);
3150
+ setModeOpen(false);
3151
+ setResumeOpen(false);
3152
+ setPluginOpen(false);
2704
3153
  setVerboseOpen(false);
2705
3154
  }, [approvalPending, questionPending]);
2706
3155
  const settled = useMemo(() => settledEntryCount(view.entries), [view.entries]);
@@ -2715,14 +3164,19 @@ function App(props) {
2715
3164
  showReasoning,
2716
3165
  verbose: false
2717
3166
  });
2718
- if (entry.kind === "user" && index > 0) rows.push(createElement(Box, {
2719
- key: `gap-${index}`,
3167
+ const roomyPrompt = entry.kind === "user" && !entry.notice;
3168
+ if (roomyPrompt) rows.push(createElement(Box, {
3169
+ key: `prompt-before-${index}`,
2720
3170
  paddingX: 1
2721
3171
  }, createElement(Text, null, " ")));
2722
3172
  rows.push(createElement(Box, {
2723
3173
  key: index,
2724
3174
  paddingX: 1
2725
3175
  }, row));
3176
+ if (roomyPrompt) rows.push(createElement(Box, {
3177
+ key: `prompt-after-${index}`,
3178
+ paddingX: 1
3179
+ }, createElement(Text, null, " ")));
2726
3180
  });
2727
3181
  return rows;
2728
3182
  }, [
@@ -2762,7 +3216,8 @@ function App(props) {
2762
3216
  }, [appStdout]);
2763
3217
  const terminalRows = terminalSize.rows;
2764
3218
  const terminalColumns = terminalSize.columns;
2765
- const dynamicRows = Math.max(1, terminalRows - 12);
3219
+ const composerGutterRows = layoutGutterRows(terminalRows);
3220
+ const dynamicRows = Math.max(1, terminalRows - 12 - composerGutterRows);
2766
3221
  const streamingActive = view.streaming !== "" || view.streamingReasoning !== "";
2767
3222
  const deepDivingVisible = busy && !streamingActive;
2768
3223
  const allLiveLines = useMemo(() => view.entries.slice(settled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))), [
@@ -2775,8 +3230,8 @@ function App(props) {
2775
3230
  const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length);
2776
3231
  const reasoningRows = view.streamingReasoning === "" ? 0 : view.streaming === "" ? streamRows : streamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(streamRows / 3)) : 1;
2777
3232
  const answerRows = view.streaming === "" ? 0 : Math.max(1, streamRows - reasoningRows);
2778
- const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending;
2779
- const modalVisible = modelOpen || helpOpen || verboseOpen && !approvalPending && !questionPending || approvalPending || questionPending;
3233
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending;
3234
+ const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || verboseOpen && !approvalPending && !questionPending || approvalPending || questionPending;
2780
3235
  const closeInspector = useCallback(() => {
2781
3236
  setVerboseOpen(false);
2782
3237
  }, []);
@@ -2801,17 +3256,25 @@ function App(props) {
2801
3256
  maxRows: answerRows
2802
3257
  }, busy ? createElement(Caret) : void 0) : void 0, deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : void 0) : void 0, transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : void 0, createElement(QuestionBar, {
2803
3258
  store: props.questions,
3259
+ snapshot: questionSnapshot,
2804
3260
  locked: false
2805
3261
  }), createElement(ApprovalBar, {
2806
- approval: props.approval,
3262
+ snapshot: approvalSnapshot,
2807
3263
  locked: questionPending
2808
3264
  }), modelOpen && !approvalPending && !questionPending ? createElement(ModelPanel, {
2809
3265
  directory,
2810
3266
  error: modelError,
2811
3267
  onSelect: (row) => {
2812
- setModelLabel(props.selectModel(row));
2813
- notify(`model → next step uses ${row.provider}/${row.model}`);
2814
- setModelOpen(false);
3268
+ try {
3269
+ setModelLabel(props.selectModel(row));
3270
+ notify(`model → next step uses ${row.provider}/${row.model}`);
3271
+ setModelOpen(false);
3272
+ } catch (error) {
3273
+ notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3274
+ }
3275
+ },
3276
+ onRetry: () => {
3277
+ setModelLoadEpoch((epoch) => epoch + 1);
2815
3278
  },
2816
3279
  onClose: () => {
2817
3280
  setModelOpen(false);
@@ -2819,17 +3282,45 @@ function App(props) {
2819
3282
  }) : void 0, helpOpen && !approvalPending && !questionPending ? createElement(HelpPanel, {
2820
3283
  descriptors,
2821
3284
  skills,
3285
+ commandError: props.commands.error,
3286
+ skillError: props.skills.error,
2822
3287
  onClose: () => {
2823
3288
  setHelpOpen(false);
2824
3289
  }
2825
3290
  }) : void 0, verboseOpen && !approvalPending && !questionPending ? createElement(MemoVerbosePanel, {
2826
3291
  entries: view.entries,
2827
3292
  onClose: closeInspector
2828
- }) : void 0, transcriptVisible ? createElement(Box, { flexDirection: "column" }, ...notices.slice(-1).map((notice, index) => createElement(Text, {
2829
- key: index,
2830
- dimColor: true,
2831
- wrap: "truncate-end"
2832
- }, displayText(notice)))) : void 0, createElement(Box, { flexDirection: "column" }, createElement(Input, {
3293
+ }) : void 0, modeOpen && !approvalPending && !questionPending ? createElement(ModePanel, {
3294
+ current: props.mode,
3295
+ load: props.loadPresets,
3296
+ select: (id) => {
3297
+ props.switchMode(id).then((label) => {
3298
+ notify(`mode → ${label}`);
3299
+ setModeOpen(false);
3300
+ }, (reason) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, "error"));
3301
+ },
3302
+ close: () => setModeOpen(false)
3303
+ }) : void 0, resumeOpen && !approvalPending && !questionPending ? createElement(ResumePanel, {
3304
+ currentCwd: props.workspaceRoot,
3305
+ load: props.loadSessions,
3306
+ readTranscript: props.loadSessionTranscript,
3307
+ select: (row) => {
3308
+ props.switchSession(row);
3309
+ setResumeOpen(false);
3310
+ },
3311
+ close: () => setResumeOpen(false)
3312
+ }) : void 0, pluginOpen && !approvalPending && !questionPending ? createElement(PluginPanel, {
3313
+ load: props.loadPlugins,
3314
+ initialQuery: pluginQuery,
3315
+ close: () => setPluginOpen(false)
3316
+ }) : void 0, notice === void 0 ? void 0 : createElement(NoticeLine, {
3317
+ text: notice.text,
3318
+ tone: notice.tone,
3319
+ columns: terminalColumns
3320
+ }), createElement(Box, {
3321
+ flexDirection: "column",
3322
+ marginTop: composerGutterRows
3323
+ }, createElement(Input, {
2833
3324
  active: inputActive,
2834
3325
  frozen: modalVisible,
2835
3326
  busy,
@@ -2840,12 +3331,26 @@ function App(props) {
2840
3331
  interrupt: props.interrupt,
2841
3332
  quit: props.quit,
2842
3333
  openModel: () => {
3334
+ setDirectory(void 0);
3335
+ setModelError(void 0);
2843
3336
  setModelOpen(true);
2844
3337
  },
2845
3338
  openHelp: () => {
2846
3339
  setHelpOpen(true);
2847
3340
  },
3341
+ openMode: () => setModeOpen(true),
3342
+ openResume: () => setResumeOpen(true),
3343
+ openPlugin: (query = "") => {
3344
+ setPluginQuery(query);
3345
+ setPluginOpen(true);
3346
+ },
3347
+ createSession: props.createSession,
3348
+ cancelSessionSwitch: props.cancelSessionSwitch,
2848
3349
  notify,
3350
+ hasNotice: notice !== void 0,
3351
+ dismissNotice: () => {
3352
+ setNotice(void 0);
3353
+ },
2849
3354
  openVerbose: () => {
2850
3355
  setVerboseOpen(true);
2851
3356
  },
@@ -2863,6 +3368,7 @@ function App(props) {
2863
3368
  }), createElement(StatusLine, {
2864
3369
  facts: {
2865
3370
  model: modelLabel,
3371
+ mode: props.mode,
2866
3372
  cwd: props.cwd,
2867
3373
  branch: props.branch,
2868
3374
  sessionId: props.sessionId,
@@ -2975,10 +3481,17 @@ function watchCommands(ctx) {
2975
3481
  const commands = ctx.get("commands");
2976
3482
  let agent;
2977
3483
  let descriptors = [];
3484
+ let error;
2978
3485
  const listeners = /* @__PURE__ */ new Set();
2979
3486
  const refresh = () => {
2980
3487
  if (commands === void 0 || agent === void 0) return;
2981
- descriptors = commands.list(agent);
3488
+ try {
3489
+ descriptors = commands.list(agent);
3490
+ error = void 0;
3491
+ } catch (cause) {
3492
+ descriptors = [...descriptors];
3493
+ error = cause instanceof Error ? cause.message : String(cause);
3494
+ }
2982
3495
  for (const listener of listeners) listener();
2983
3496
  };
2984
3497
  if (commands !== void 0) ctx.on("commands/change", () => refresh());
@@ -2986,6 +3499,9 @@ function watchCommands(ctx) {
2986
3499
  get descriptors() {
2987
3500
  return descriptors;
2988
3501
  },
3502
+ get error() {
3503
+ return error;
3504
+ },
2989
3505
  subscribe(listener) {
2990
3506
  listeners.add(listener);
2991
3507
  return () => {
@@ -3019,9 +3535,14 @@ function isSlashLine(line) {
3019
3535
  const internals = {
3020
3536
  mount: (element) => {
3021
3537
  const instance = render(element);
3022
- return { unmount() {
3023
- instance.unmount();
3024
- } };
3538
+ return {
3539
+ rerender(element) {
3540
+ instance.rerender(element);
3541
+ },
3542
+ unmount() {
3543
+ instance.unmount();
3544
+ }
3545
+ };
3025
3546
  },
3026
3547
  stderr: process.stderr
3027
3548
  };
@@ -3345,24 +3866,36 @@ function watchSkills(ctx) {
3345
3866
  const skills = ctx.get("skills");
3346
3867
  let agent;
3347
3868
  let rows = [];
3869
+ let error;
3348
3870
  const listeners = /* @__PURE__ */ new Set();
3349
3871
  const reload = () => {
3350
- if (skills === void 0 || agent === void 0) return;
3351
- skills.list({
3352
- cwd: agent.session.header.cwd,
3353
- scope: agent
3354
- }).then((summaries) => {
3872
+ const currentAgent = agent;
3873
+ if (skills === void 0 || currentAgent === void 0) return;
3874
+ Promise.resolve().then(() => skills.list({
3875
+ cwd: currentAgent.session.header.cwd,
3876
+ scope: currentAgent
3877
+ })).then((summaries) => {
3355
3878
  const next = toRows(summaries);
3356
- if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return;
3879
+ const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name);
3357
3880
  rows = next;
3881
+ const recovered = error !== void 0;
3882
+ error = void 0;
3883
+ if (unchanged && !recovered) return;
3358
3884
  for (const listener of listeners) listener();
3359
- }, () => {});
3885
+ }).catch((cause) => {
3886
+ rows = [...rows];
3887
+ error = cause instanceof Error ? cause.message : String(cause);
3888
+ for (const listener of listeners) listener();
3889
+ });
3360
3890
  };
3361
3891
  if (skills !== void 0) ctx.on("skills/change", reload);
3362
3892
  return {
3363
3893
  get rows() {
3364
3894
  return rows;
3365
3895
  },
3896
+ get error() {
3897
+ return error;
3898
+ },
3366
3899
  subscribe(listener) {
3367
3900
  listeners.add(listener);
3368
3901
  return () => {
@@ -3443,15 +3976,152 @@ function buildExportMarkdown(view, sessionId) {
3443
3976
  return out.join("\n");
3444
3977
  }
3445
3978
  //#endregion
3979
+ //#region src/session-switch.ts
3980
+ var SessionSwitchQueue = class {
3981
+ execute;
3982
+ failed;
3983
+ pending;
3984
+ pumping = false;
3985
+ constructor(execute, failed) {
3986
+ this.execute = execute;
3987
+ this.failed = failed;
3988
+ }
3989
+ /** Queue a request; a later request replaces any request still waiting. */
3990
+ request(activity, value) {
3991
+ this.pending = {
3992
+ activity,
3993
+ value
3994
+ };
3995
+ const outcome = activity.status === "running" || this.pumping ? "queued" : "started";
3996
+ if (!this.pumping) this.pump();
3997
+ return outcome;
3998
+ }
3999
+ /** Cancel only work that has not begun activation. */
4000
+ cancel() {
4001
+ if (this.pending === void 0) return false;
4002
+ this.pending = void 0;
4003
+ return true;
4004
+ }
4005
+ async pump() {
4006
+ this.pumping = true;
4007
+ try {
4008
+ while (this.pending !== void 0) {
4009
+ const observed = this.pending;
4010
+ await observed.activity.whenIdle();
4011
+ if (this.pending !== observed) continue;
4012
+ this.pending = void 0;
4013
+ try {
4014
+ await this.execute(observed.value);
4015
+ } catch (error) {
4016
+ this.failed(error);
4017
+ }
4018
+ }
4019
+ } finally {
4020
+ this.pumping = false;
4021
+ if (this.pending !== void 0) this.pump();
4022
+ }
4023
+ }
4024
+ };
4025
+ //#endregion
4026
+ //#region src/presets.ts
4027
+ /** Read an optional Cordis service without requiring its package at build time. */
4028
+ function agentPresetsFrom(ctx) {
4029
+ return ctx.get("agentPresets");
4030
+ }
4031
+ /** A preset may change only before the first durable turn begins. */
4032
+ function isBlankSession(events) {
4033
+ return !events.some((event) => event.type === "turn/start");
4034
+ }
4035
+ /** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
4036
+ function resolvePreset(session) {
4037
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
4038
+ const event = session.events[index];
4039
+ if (event.type === "agent-preset/selected" && event.data?.agentPreset !== void 0) return event.data.agentPreset;
4040
+ }
4041
+ return session.header.agentPreset ?? "standard";
4042
+ }
4043
+ /** Recompose atomically from the caller's perspective, logging only success. */
4044
+ async function switchPreset(service, agent, presetId) {
4045
+ if (!isBlankSession(agent.session.events)) throw new Error("mode is locked after the first turn; use /new <mode>");
4046
+ const preset = await service.recompose(agent.ctx, presetId);
4047
+ agent.session.append("agent-preset/selected", { agentPreset: preset.id });
4048
+ return preset;
4049
+ }
4050
+ //#endregion
4051
+ //#region src/plugin-inventory.ts
4052
+ const PHASES = {
4053
+ 0: "pending",
4054
+ 1: "loading",
4055
+ 2: "active",
4056
+ 3: "failed",
4057
+ 4: null,
4058
+ 5: "unloading"
4059
+ };
4060
+ /** Snapshot the live Loader; group-only rows are composition containers, not plugins. */
4061
+ function listPluginRows(ctx) {
4062
+ const loader = ctx.get("loader");
4063
+ if (loader === void 0) return [];
4064
+ const rows = [];
4065
+ for (const entry of loader.entries()) {
4066
+ if (entry.options.group === true) continue;
4067
+ rows.push({
4068
+ entryId: entry.id,
4069
+ moduleName: entry.options.name,
4070
+ enabled: !entry.disabled,
4071
+ phase: entry.fiber === void 0 ? null : PHASES[entry.fiber.state] ?? null
4072
+ });
4073
+ }
4074
+ return rows;
4075
+ }
4076
+ //#endregion
4077
+ //#region src/session-directory.ts
4078
+ /** Lightweight session-directory projection for the /resume picker. */
4079
+ function samePath(left, right) {
4080
+ if (left === void 0) return false;
4081
+ return resolve(left).toLowerCase() === resolve(right).toLowerCase();
4082
+ }
4083
+ /** Filter/sort header-only records. No session log is loaded here. */
4084
+ function projectSessionRows(records, options) {
4085
+ const needle = options.query.trim().toLowerCase();
4086
+ return records.filter((record) => options.sessions === "all" || record.header.parentSession === void 0 && record.header.origin !== "subagent").filter((record) => options.cwd === "all" || samePath(record.header.cwd, options.currentCwd)).map((record) => {
4087
+ const cwd = record.header.cwd ?? "";
4088
+ const subagent = record.header.origin === "subagent" || record.header.parentSession !== void 0;
4089
+ return {
4090
+ id: record.header.id,
4091
+ createdAt: record.header.createdAt,
4092
+ cwd,
4093
+ workspace: cwd === "" ? "(no workspace)" : basename(cwd),
4094
+ parent: record.header.parentSession,
4095
+ subagent,
4096
+ resumable: !subagent,
4097
+ live: record.live,
4098
+ persisted: record.persisted,
4099
+ preset: record.header.agentPreset ?? "standard"
4100
+ };
4101
+ }).filter((row) => needle === "" || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle)).sort((left, right) => options.sort === "newest" ? right.createdAt - left.createdAt : left.createdAt - right.createdAt);
4102
+ }
4103
+ /** Merge page-local title observations without disturbing directory order. */
4104
+ function mergeSessionTitles(rows, observations) {
4105
+ const titles = /* @__PURE__ */ new Map();
4106
+ for (const observation of observations) {
4107
+ if (observation.status !== "fulfilled") continue;
4108
+ const title = observation.value?.title?.title ?? observation.value?.title?.text;
4109
+ if (title !== void 0 && title.trim() !== "") titles.set(observation.sessionId, title);
4110
+ }
4111
+ return rows.map((row) => titles.has(row.id) ? {
4112
+ ...row,
4113
+ title: titles.get(row.id)
4114
+ } : row);
4115
+ }
4116
+ //#endregion
3446
4117
  //#region src/index.ts
3447
4118
  /**
3448
4119
  * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3449
4120
  * rides over dsh-base without Host, HTTP, or browser plugins; this runner
3450
- * creates (or resumes) one Agent through the core registry, mounts the Ink
3451
- * app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
3452
- * durable session, answers approval asks with a y/n bar, dispatches slash
3453
- * commands through the shared registry, and on quit flushes and requests
3454
- * process exit.
4121
+ * creates or resumes preset-composed Agents through the core registry, keeps
4122
+ * one Ink owner while the active session changes, folds submitted prompts
4123
+ * into the selected durable session, answers approval asks with a y/n bar,
4124
+ * dispatches slash commands, and on quit flushes and requests process exit.
3455
4125
  *
3456
4126
  * @module @deepseek-ai/dsh-code
3457
4127
  */
@@ -3465,7 +4135,8 @@ const inject = [
3465
4135
  ];
3466
4136
  const Config = z.object({ startup: z.object({
3467
4137
  kind: z.string().required(),
3468
- sessionId: z.string()
4138
+ sessionId: z.string(),
4139
+ mode: z.string()
3469
4140
  }) });
3470
4141
  /** Report an unexpected direct-driver failure and request a failing exit. */
3471
4142
  function fail(io, error) {
@@ -3495,11 +4166,13 @@ function gitBranch(cwd) {
3495
4166
  async function resolveTarget(startup, persistence, cwd) {
3496
4167
  if (startup.kind === "fresh") return {
3497
4168
  sessionId: `session-${randomUUID()}`,
3498
- resume: false
4169
+ resume: false,
4170
+ mode: startup.mode
3499
4171
  };
3500
4172
  if (startup.kind === "named") return {
3501
4173
  sessionId: startup.sessionId,
3502
- resume: false
4174
+ resume: false,
4175
+ mode: startup.mode
3503
4176
  };
3504
4177
  if (persistence === void 0) throw new Error("cannot resolve the requested session: session persistence is not configured");
3505
4178
  const headers = await persistence.list();
@@ -3550,63 +4223,76 @@ async function run(ctx, startup, io) {
3550
4223
  const defaultModel = ctx.get("agentDefaultModel");
3551
4224
  const sessions = ctx.get("sessions");
3552
4225
  const persistence = ctx.get("sessionPersistence");
4226
+ const sessionQuery = ctx.get("sessionQuery");
3553
4227
  if (agents === void 0 || defaultModel === void 0 || sessions === void 0) return;
3554
4228
  const cwd = process.cwd();
3555
4229
  const target = await resolveTarget(startup, persistence, cwd);
3556
4230
  const defaults = defaultModel.currentSelection();
3557
- let picked;
3558
- let session;
3559
- let agent;
3560
- if (target.resume) {
3561
- agent = (await agents.resume({
3562
- resumeSessionId: SessionId(target.sessionId),
4231
+ const presets = agentPresetsFrom(ctx);
4232
+ if (presets === void 0) throw new Error("agent preset service is unavailable; check the dsh-code bundle patch");
4233
+ /** Prepare a complete next session before disturbing the currently visible one. */
4234
+ const prepare = async (next) => {
4235
+ const nextCwd = next.cwd ?? cwd;
4236
+ const selectionState = {};
4237
+ let mode = next.mode;
4238
+ if (!next.resume) mode = (await presets.resolve(mode)).id;
4239
+ const setup = async (agentCtx) => {
4240
+ const sessionPreset = next.resume ? resolvePreset(agentCtx.agent.session) : mode;
4241
+ mode = (await presets.mount(agentCtx, sessionPreset)).id;
4242
+ installModelSelection(agentCtx, {
4243
+ get current() {
4244
+ if (selectionState.picked !== void 0) return selectionState.picked;
4245
+ const logged = agentCtx.agent?.session.requestHeader()?.config;
4246
+ if (logged !== void 0) return {
4247
+ provider: logged.provider,
4248
+ model: logged.model,
4249
+ ...logged.reasoningEffort === void 0 ? {} : { reasoningEffort: logged.reasoningEffort }
4250
+ };
4251
+ return defaults;
4252
+ },
4253
+ set current(value) {
4254
+ selectionState.picked = value;
4255
+ },
4256
+ assembled: void 0
4257
+ });
4258
+ };
4259
+ const handle = next.resume ? await agents.resume({
4260
+ resumeSessionId: SessionId(next.sessionId),
3563
4261
  agentOptions: {
3564
4262
  provider: defaults.provider,
3565
4263
  model: defaults.model
3566
4264
  },
3567
- setup: (agentCtx) => {
3568
- installModelSelection(agentCtx, {
3569
- get current() {
3570
- if (picked !== void 0) return picked;
3571
- const logged = agentCtx.agent?.session.requestHeader()?.config;
3572
- if (logged !== void 0) return {
3573
- provider: logged.provider,
3574
- model: logged.model,
3575
- ...logged.reasoningEffort === void 0 ? {} : { reasoningEffort: logged.reasoningEffort }
3576
- };
3577
- return defaults;
3578
- },
3579
- set current(next) {
3580
- picked = next;
3581
- },
3582
- assembled: void 0
3583
- });
3584
- }
3585
- })).agent;
3586
- session = agent.session;
3587
- } else {
3588
- agent = (await agents.create({
3589
- sessionId: SessionId(target.sessionId),
3590
- meta: { cwd },
4265
+ setup
4266
+ }) : await agents.create({
4267
+ sessionId: SessionId(next.sessionId),
4268
+ meta: {
4269
+ cwd: nextCwd,
4270
+ agentPreset: mode
4271
+ },
3591
4272
  agentOptions: {
3592
4273
  provider: defaults.provider,
3593
4274
  model: defaults.model
3594
4275
  },
3595
- setup: (agentCtx) => {
3596
- installModelSelection(agentCtx, {
3597
- get current() {
3598
- return picked ?? defaults;
3599
- },
3600
- set current(next) {
3601
- picked = next;
3602
- },
3603
- assembled: void 0
3604
- });
3605
- }
3606
- })).agent;
3607
- session = agent.session;
3608
- }
3609
- const store = createTranscriptStore(session.events);
4276
+ setup
4277
+ });
4278
+ const session = handle.agent.session;
4279
+ const sessionCwd = session.header.cwd ?? nextCwd;
4280
+ return {
4281
+ handle,
4282
+ agent: handle.agent,
4283
+ session,
4284
+ store: createTranscriptStore(session.events),
4285
+ mentions: createMentions(ctx, handle.agent, sessionCwd),
4286
+ mode: mode ?? "standard",
4287
+ selection: selectionState,
4288
+ resumed: next.resume
4289
+ };
4290
+ };
4291
+ let active = await prepare(target);
4292
+ let agent = active.agent;
4293
+ let session = active.session;
4294
+ let store = active.store;
4295
+ let mentions = active.mentions;
3610
4296
  const off = ctx.on("session/event", (subject, event) => {
3611
4297
  if (subject.id === session.id) store.apply(event);
3612
4298
  });
@@ -3616,39 +4302,53 @@ async function run(ctx, startup, io) {
3616
4302
  skills.setAgent(agent);
3617
4303
  const approval = mountApprovalAnswerer(ctx, (candidate) => candidate.id === agent.id, (request) => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
3618
4304
  const questions = mountQuestionProvider(ctx);
3619
- const mentions = createMentions(ctx, agent, session.header.cwd ?? cwd);
3620
4305
  const bridge = { notify: () => {} };
3621
4306
  const mountRef = {};
3622
4307
  let quitting = false;
3623
4308
  const quit = () => {
3624
4309
  if (quitting) return;
3625
4310
  quitting = true;
4311
+ switchQueue.cancel();
3626
4312
  off();
3627
4313
  mountRef.current?.unmount();
3628
4314
  sessions.flush(session).catch((flushError) => {
3629
4315
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`);
4316
+ }).then(() => active.handle.dispose()).catch((disposeError) => {
4317
+ internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`);
3630
4318
  }).then(() => {
3631
4319
  io.exit(0);
3632
4320
  });
3633
4321
  };
3634
4322
  /** Run one slash line through the command registry (closed namespace). */
3635
4323
  const runSlash = (line) => {
4324
+ if (line.startsWith("/mode ")) {
4325
+ switchModeAction(line.slice(6).trim());
4326
+ return;
4327
+ }
4328
+ if (line.startsWith("/resume ")) {
4329
+ requestResume(line.slice(8).trim());
4330
+ return;
4331
+ }
3636
4332
  const registry = ctx.get("commands");
3637
4333
  if (registry === void 0) {
3638
- bridge.notify("no command registry is mounted in this composition");
4334
+ bridge.notify("no command registry is mounted in this composition", "error");
3639
4335
  return;
3640
4336
  }
3641
4337
  const controller = new AbortController();
3642
- registry.execute(agent, line, controller.signal).then((execution) => {
3643
- if (execution === void 0) agent.followup(createUserMessage({
3644
- content: [{
3645
- type: "text",
3646
- text: line
3647
- }],
3648
- source: { kind: "user" }
3649
- }));
4338
+ Promise.resolve().then(() => registry.execute(agent, line, controller.signal)).then((execution) => {
4339
+ if (execution === void 0) try {
4340
+ agent.followup(createUserMessage({
4341
+ content: [{
4342
+ type: "text",
4343
+ text: line
4344
+ }],
4345
+ source: { kind: "user" }
4346
+ }));
4347
+ } catch (error) {
4348
+ bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, "error");
4349
+ }
3650
4350
  }, (error) => {
3651
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
4351
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3652
4352
  });
3653
4353
  };
3654
4354
  /** Deliver one readable line to the agent, expanding session mentions first. */
@@ -3663,22 +4363,26 @@ async function run(ctx, startup, io) {
3663
4363
  try {
3664
4364
  parsed = mentions.parse(line);
3665
4365
  } catch (error) {
3666
- bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`);
4366
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, "error");
3667
4367
  return;
3668
4368
  }
3669
4369
  const deliver = (readable, context) => {
3670
- if (context !== void 0) agent.inject(context);
3671
- const message = createUserMessage({
3672
- content: [{
3673
- type: "text",
3674
- text: readable
3675
- }],
3676
- source: { kind: "user" }
3677
- });
3678
- if (mode === "steer") {
3679
- agent.steer(message);
3680
- bridge.notify("steering queued — the next step sees it");
3681
- } else agent.followup(message);
4370
+ try {
4371
+ if (context !== void 0) agent.inject(context);
4372
+ const message = createUserMessage({
4373
+ content: [{
4374
+ type: "text",
4375
+ text: readable
4376
+ }],
4377
+ source: { kind: "user" }
4378
+ });
4379
+ if (mode === "steer") {
4380
+ agent.steer(message);
4381
+ bridge.notify("steering queued — the next step sees it");
4382
+ } else agent.followup(message);
4383
+ } catch (error) {
4384
+ bridge.notify(`${mode === "steer" ? "steering" : "message"} failed: ${error instanceof Error ? error.message : String(error)}`, "error");
4385
+ }
3682
4386
  };
3683
4387
  if (parsed.references.length === 0) {
3684
4388
  deliver(parsed.text);
@@ -3689,7 +4393,7 @@ async function run(ctx, startup, io) {
3689
4393
  deliver(prepared.text, prepared.additionalContext);
3690
4394
  }, (error) => {
3691
4395
  if (controller.signal.aborted) return;
3692
- bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`);
4396
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3693
4397
  });
3694
4398
  };
3695
4399
  /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
@@ -3707,9 +4411,14 @@ async function run(ctx, startup, io) {
3707
4411
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
3708
4412
  const interrupt = () => {
3709
4413
  if (agent.status !== "running") return false;
3710
- agent.cancel({ kind: "user" });
3711
- bridge.notify("turn cancelled Ctrl+C or /quit to exit");
3712
- return true;
4414
+ try {
4415
+ agent.cancel({ kind: "user" });
4416
+ bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
4417
+ return true;
4418
+ } catch (error) {
4419
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, "error");
4420
+ return false;
4421
+ }
3713
4422
  };
3714
4423
  /**
3715
4424
  * Cycle to the next permission preset (Shift+Tab, the Claude-Code
@@ -3719,18 +4428,23 @@ async function run(ctx, startup, io) {
3719
4428
  const cyclePermission = () => {
3720
4429
  const service = ctx.get("permissionPresets");
3721
4430
  if (service === void 0 || service.names.length === 0) {
3722
- bridge.notify("permission presets are not mounted in this composition");
4431
+ bridge.notify("permission presets are not mounted in this composition", "warning");
3723
4432
  return "";
3724
4433
  }
3725
4434
  const at = service.names.indexOf(service.current(session.events));
3726
4435
  const next = service.names[(at + 1) % service.names.length] ?? "";
3727
4436
  if (next === "") return "";
3728
- service.set(session, next);
3729
- return next;
4437
+ try {
4438
+ service.set(session, next);
4439
+ return next;
4440
+ } catch (error) {
4441
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
4442
+ return "";
4443
+ }
3730
4444
  };
3731
4445
  /** Apply one /model selection: takes effect from the next assembled step. */
3732
4446
  const selectModel = (row) => {
3733
- picked = {
4447
+ active.selection.picked = {
3734
4448
  provider: row.provider,
3735
4449
  model: row.model
3736
4450
  };
@@ -3743,14 +4457,15 @@ async function run(ctx, startup, io) {
3743
4457
  */
3744
4458
  const exportTranscript = async (argument) => {
3745
4459
  const wanted = argument.trim();
4460
+ const sessionCwd = session.header.cwd ?? cwd;
3746
4461
  const defaultName = `dsh-session-${session.id.slice(-8)}.md`;
3747
- const target = wanted === "" ? join(cwd, defaultName) : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith("/") ? wanted : join(cwd, wanted);
4462
+ const target = wanted === "" ? join(sessionCwd, defaultName) : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith("/") ? wanted : join(sessionCwd, wanted);
3748
4463
  const markdown = buildExportMarkdown(store.getView(), session.id);
3749
4464
  try {
3750
4465
  await writeFile(target, `${markdown}\n`, "utf8");
3751
4466
  bridge.notify(`exported to ${target}`);
3752
4467
  } catch (error) {
3753
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`);
4468
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3754
4469
  }
3755
4470
  };
3756
4471
  /**
@@ -3770,32 +4485,171 @@ async function run(ctx, startup, io) {
3770
4485
  return `rename failed: ${error instanceof Error ? error.message : String(error)}`;
3771
4486
  }
3772
4487
  };
3773
- const initialModel = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
3774
- mountRef.current = io.mount(createElement(App, {
3775
- store,
3776
- approval,
3777
- questions,
3778
- commands,
3779
- skills,
3780
- model: initialModel,
3781
- cwd: basename(cwd),
3782
- branch: gitBranch(cwd),
3783
- sessionId: session.id.slice(-8),
3784
- resumed: target.resume,
3785
- dispatch,
3786
- steer,
3787
- interrupt,
3788
- quit,
3789
- loadModels: () => loadModelDirectory(ctx),
3790
- loadMentions: mentions.candidates,
3791
- cyclePermission,
3792
- selectModel,
3793
- exportTranscript,
3794
- renameTitle,
3795
- onBridgeReady: (instance) => {
3796
- bridge.notify = instance.notify;
4488
+ const loadSessions = async (options, signal) => {
4489
+ if (sessionQuery === void 0) throw new Error("session query is unavailable in this profile");
4490
+ const projected = projectSessionRows(await sessionQuery.listSessions(signal), options);
4491
+ const page = projected.slice(0, 32);
4492
+ if (page.length === 0) return projected;
4493
+ return mergeSessionTitles(projected, await sessionQuery.readTitleSnapshots(page.map((row) => row.id), signal));
4494
+ };
4495
+ const loadSessionTranscript = async (id, signal) => {
4496
+ if (sessionQuery === void 0) throw new Error("session query is unavailable in this profile");
4497
+ const snapshot = await sessionQuery.readSession(id, signal);
4498
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id);
4499
+ };
4500
+ const switchModeAction = async (id) => {
4501
+ if (id === "") throw new Error("usage: /mode <preset>");
4502
+ const preset = await switchPreset(presets, agent, id);
4503
+ active.mode = preset.id;
4504
+ commands.setAgent(agent);
4505
+ skills.setAgent(agent);
4506
+ renderCurrent();
4507
+ return preset.id;
4508
+ };
4509
+ const activate = async (nextTarget) => {
4510
+ const previous = active;
4511
+ const next = await prepare(nextTarget);
4512
+ active = next;
4513
+ agent = next.agent;
4514
+ session = next.session;
4515
+ store = next.store;
4516
+ mentions = next.mentions;
4517
+ commands.setAgent(agent);
4518
+ skills.setAgent(agent);
4519
+ try {
4520
+ process.stdout.write("\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H");
4521
+ renderCurrent();
4522
+ } catch (error) {
4523
+ active = previous;
4524
+ agent = previous.agent;
4525
+ session = previous.session;
4526
+ store = previous.store;
4527
+ mentions = previous.mentions;
4528
+ commands.setAgent(agent);
4529
+ skills.setAgent(agent);
4530
+ await next.handle.dispose();
4531
+ renderCurrent();
4532
+ throw error;
3797
4533
  }
3798
- }));
4534
+ let cleanupWarning;
4535
+ try {
4536
+ await sessions.flush(previous.session);
4537
+ } catch (error) {
4538
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`;
4539
+ }
4540
+ try {
4541
+ await previous.handle.dispose();
4542
+ } catch (error) {
4543
+ cleanupWarning = `${cleanupWarning === void 0 ? "" : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`;
4544
+ }
4545
+ bridge.notify(cleanupWarning === void 0 ? `${next.resumed ? "resumed" : "created"} ${next.session.id.slice(-12)} · mode ${next.mode}` : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`, cleanupWarning === void 0 ? "info" : "warning");
4546
+ };
4547
+ const switchQueue = new SessionSwitchQueue(async (request) => {
4548
+ if (!quitting) await activate(request.target);
4549
+ }, (error) => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, "error"));
4550
+ const requestSwitch = (request) => {
4551
+ if (request.target.sessionId === session.id) {
4552
+ bridge.notify("that session is already active", "warning");
4553
+ return;
4554
+ }
4555
+ if (switchQueue.request(agent, request) === "queued") bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`);
4556
+ };
4557
+ const resolveResumeId = async (wanted) => {
4558
+ if (wanted === "") throw new Error("usage: /resume <id|prefix>");
4559
+ if (sessionQuery === void 0) throw new Error("session query is unavailable in this profile");
4560
+ const records = await sessionQuery.listSessions();
4561
+ const exact = records.filter((record) => record.header.id === wanted);
4562
+ const matches = exact.length > 0 ? exact : records.filter((record) => record.header.id.startsWith(wanted));
4563
+ if (matches.length === 0) throw new Error(`no session matches "${wanted}"`);
4564
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`);
4565
+ if (matches[0].header.parentSession !== void 0 || matches[0].header.origin === "subagent") throw new Error("subagent conversations are read-only in /resume; resume a root session");
4566
+ if (agents.get(SessionId(matches[0].header.id)) !== void 0 && matches[0].header.id !== session.id) throw new Error("that session is already live in another owner");
4567
+ return matches[0].header.id;
4568
+ };
4569
+ const requestResume = (wanted) => {
4570
+ resolveResumeId(wanted).then((id) => {
4571
+ requestSwitch({
4572
+ target: {
4573
+ sessionId: id,
4574
+ resume: true
4575
+ },
4576
+ label: id.slice(-12)
4577
+ });
4578
+ }, (error) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, "error"));
4579
+ };
4580
+ const createSession = (mode) => {
4581
+ const nextCwd = session.header.cwd ?? cwd;
4582
+ const id = `session-${randomUUID()}`;
4583
+ requestSwitch({
4584
+ target: {
4585
+ sessionId: id,
4586
+ resume: false,
4587
+ mode,
4588
+ cwd: nextCwd
4589
+ },
4590
+ label: id.slice(-12)
4591
+ });
4592
+ };
4593
+ const switchSession = (row) => {
4594
+ if (!row.resumable) {
4595
+ bridge.notify("subagent conversations are read-only", "warning");
4596
+ return;
4597
+ }
4598
+ requestSwitch({
4599
+ target: {
4600
+ sessionId: row.id,
4601
+ resume: true
4602
+ },
4603
+ label: row.title ?? row.id.slice(-12)
4604
+ });
4605
+ };
4606
+ const cancelSessionSwitch = () => {
4607
+ return switchQueue.cancel();
4608
+ };
4609
+ const appElement = () => {
4610
+ const sessionCwd = session.header.cwd ?? cwd;
4611
+ const model = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
4612
+ return createElement(App, {
4613
+ key: session.id,
4614
+ store,
4615
+ approval,
4616
+ questions,
4617
+ commands,
4618
+ skills,
4619
+ model,
4620
+ cwd: basename(sessionCwd),
4621
+ workspaceRoot: sessionCwd,
4622
+ branch: gitBranch(sessionCwd),
4623
+ sessionId: session.id.slice(-8),
4624
+ resumed: active.resumed,
4625
+ mode: active.mode,
4626
+ dispatch,
4627
+ steer,
4628
+ interrupt,
4629
+ quit,
4630
+ loadModels: () => loadModelDirectory(ctx),
4631
+ loadMentions: mentions.candidates,
4632
+ cyclePermission,
4633
+ selectModel,
4634
+ exportTranscript,
4635
+ renameTitle,
4636
+ loadPresets: () => presets.list(),
4637
+ switchMode: switchModeAction,
4638
+ createSession,
4639
+ loadSessions,
4640
+ loadSessionTranscript,
4641
+ switchSession,
4642
+ cancelSessionSwitch,
4643
+ loadPlugins: () => listPluginRows(ctx),
4644
+ onBridgeReady: (instance) => {
4645
+ bridge.notify = instance.notify;
4646
+ }
4647
+ });
4648
+ };
4649
+ const renderCurrent = () => {
4650
+ mountRef.current?.rerender(appElement());
4651
+ };
4652
+ mountRef.current = io.mount(appElement());
3799
4653
  }
3800
4654
  /**
3801
4655
  * Mount the interactive terminal driver.
@@ -3808,8 +4662,12 @@ function apply(ctx, config) {
3808
4662
  sessionId: config.startup.sessionId
3809
4663
  } : config.startup.kind === "latest" ? { kind: "latest" } : config.startup.kind === "named" && config.startup.sessionId !== void 0 ? {
3810
4664
  kind: "named",
3811
- sessionId: config.startup.sessionId
3812
- } : { kind: "fresh" };
4665
+ sessionId: config.startup.sessionId,
4666
+ ...config.startup.mode === void 0 ? {} : { mode: config.startup.mode }
4667
+ } : {
4668
+ kind: "fresh",
4669
+ ...config.startup.mode === void 0 ? {} : { mode: config.startup.mode }
4670
+ };
3813
4671
  const exit = ctx.get("appExit");
3814
4672
  if (exit === void 0) throw new Error("tui-runner: the launcher must provide ctx.appExit before the tree mounts");
3815
4673
  const io = {