@oh-my-pi/pi-tui 18.13.2 → 18.13.3

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +24 -14
  2. package/dist/types/apps/session-picker.d.ts +4 -0
  3. package/dist/types/chat/assistant-message.d.ts +1 -1
  4. package/dist/types/chat/bash-execution.d.ts +2 -0
  5. package/dist/types/chat/display-preferences.d.ts +2 -0
  6. package/dist/types/chat/tool-execution.d.ts +1 -1
  7. package/dist/types/components/editor.d.ts +2 -0
  8. package/dist/types/overlays/annotation-overlay.d.ts +7 -1
  9. package/dist/types/overlays/annotation-types.d.ts +28 -0
  10. package/dist/types/overlays/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/overlays/session-selector.d.ts +13 -2
  12. package/dist/types/theme/theme.d.ts +0 -23
  13. package/dist/types/tools/bash-interactive.d.ts +6 -0
  14. package/dist/types/tui.d.ts +4 -1
  15. package/package.json +10 -10
  16. package/src/apps/session-picker.ts +5 -0
  17. package/src/chat/advisor-message.ts +2 -0
  18. package/src/chat/assistant-message.ts +3 -1
  19. package/src/chat/bash-execution.ts +6 -0
  20. package/src/chat/display-preferences.ts +3 -0
  21. package/src/chat/tool-execution.ts +7 -3
  22. package/src/components/editor.ts +3 -1
  23. package/src/native/backend.ts +2 -64
  24. package/src/overlays/annotation-overlay.ts +125 -37
  25. package/src/overlays/annotation-types.ts +24 -0
  26. package/src/overlays/hook-selector.ts +2 -2
  27. package/src/overlays/plan-review-overlay.ts +13 -2
  28. package/src/overlays/session-selector.ts +195 -26
  29. package/src/overlays/usage-dashboard.ts +5 -12
  30. package/src/prompt/custom-editor.ts +6 -1
  31. package/src/terminal.ts +24 -2
  32. package/src/theme/theme.ts +0 -65
  33. package/src/tools/bash-interactive.ts +6 -1
  34. package/src/tools/job-widget.ts +13 -1
  35. package/src/tools/json-tree.ts +1 -1
  36. package/src/tui.ts +7 -2
@@ -51,8 +51,10 @@ export interface SessionSelectorEntry {
51
51
  parentSessionPath?: string;
52
52
  }
53
53
  import { shortenPath } from "../render/render-utils";
54
+ import { formTheme } from "../chrome/form-theme";
54
55
  import { HookSelectorComponent } from "./hook-selector";
55
56
  import { bottomBorder, OverlayPanel, row, topBorder } from "../chrome/overlay-box";
57
+ import { TextFormField } from "../components/form";
56
58
  import { MenuSelection, getMenuWindow } from "../components/menu-selection";
57
59
  import { formatKeyHint, formatKeyHints } from "../app-keybindings";
58
60
  import { boundKeys, interruptKey } from "../chrome/keybinding-hints";
@@ -518,6 +520,7 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
518
520
  readonly #getTerminalRows: () => number;
519
521
 
520
522
  onDeleteRequest?: (session: T) => void;
523
+ onRenameRequest?: (session: T) => void;
521
524
 
522
525
  #allSessions: T[];
523
526
  #showCwd: boolean;
@@ -810,6 +813,22 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
810
813
  }
811
814
  }
812
815
 
816
+ /** Update a session's title in place and refresh the visible list, keeping the selection. */
817
+ renameSessionTitle(sessionPath: string, title: string): void {
818
+ const entry = this.#allSessions.find(s => s.path === sessionPath);
819
+ if (!entry) return;
820
+ this.#datasetVersion++;
821
+ entry.title = title;
822
+ // The cached native item embeds the old title, and the search haystack
823
+ // caches it too: drop both so the next describe rebuilds the row and
824
+ // the literal/fuzzy search paths match the new title. Selection is
825
+ // path-keyed, so the same-query refilter keeps the cursor on the
826
+ // renamed session.
827
+ delete (entry as NativeSessionInfo)[kNativeItem];
828
+ delete (entry as SearchableSessionInfo)[kSearchTextLower];
829
+ this.#filterSessions(this.#searchInput.getValue());
830
+ }
831
+
813
832
  /** Resolve a list-local rendered-line index to a filtered-session index. */
814
833
  hitTestSession(line: number): number | undefined {
815
834
  return this.#hitRows[line];
@@ -853,6 +872,12 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
853
872
  if (selected) this.onDeleteRequest?.(selected);
854
873
  }
855
874
 
875
+ /** F2: ask the parent to rename the selected session. */
876
+ requestRename(): void {
877
+ const selected = this.#menu.selectedItem;
878
+ if (selected) this.onRenameRequest?.(selected);
879
+ }
880
+
856
881
  /** Empty the search query (the empty state's "Clear search"). */
857
882
  clearSearch(): void {
858
883
  this.#searchInput.setValue("");
@@ -1277,6 +1302,12 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
1277
1302
  this.requestDelete();
1278
1303
  return;
1279
1304
  }
1305
+ // F2 renames the selected session; as a non-printable key it never
1306
+ // collides with the search input.
1307
+ if (matchesKey(keyData, "f2")) {
1308
+ this.requestRename();
1309
+ return;
1310
+ }
1280
1311
  // Up arrow
1281
1312
  if (matchesSelectUp(keyData)) {
1282
1313
  this.#selectionMoved = true;
@@ -1331,6 +1362,12 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
1331
1362
 
1332
1363
  export interface SessionSelectorOptions<T extends SessionSelectorEntry = SessionSelectorEntry> {
1333
1364
  onDelete?: (session: T) => Promise<boolean>;
1365
+ /**
1366
+ * Rename the selected session to a user-supplied title; resolve with the
1367
+ * title as stored (the session canonicalizes it) or null when the rename
1368
+ * did not happen.
1369
+ */
1370
+ onRename?: (session: T, title: string) => Promise<string | null>;
1334
1371
  historyMatcher?: SessionHistoryMatcher;
1335
1372
  /** Loads sessions across all projects for the all-projects scope toggle (Tab). */
1336
1373
  loadAllSessions?: () => Promise<T[]>;
@@ -1378,15 +1415,20 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
1378
1415
  export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSelectorEntry> extends OverlayPanel {
1379
1416
  #sessionList: SessionList<T>;
1380
1417
  #confirmationDialog: HookSelectorComponent | null = null;
1381
- // Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
1382
- // frame. The delete dialog REPLACES the list in this slot rather than being
1383
- // appended below the picker chrome, so the picker is always
1418
+ /** Open rename dialog: an OverlayPanel hosting the rename TextFormField. */
1419
+ #renameDialog: OverlayPanel | null = null;
1420
+ /** The rename dialog's input; keystrokes route here while the dialog is open. */
1421
+ #renameField: TextFormField | null = null;
1422
+ // Hosts whichever of `#sessionList` / `#confirmationDialog` / `#renameDialog`
1423
+ // is live this frame. The delete dialog REPLACES the list in this slot rather
1424
+ // than being appended below the picker chrome, so the picker is always
1384
1425
  // `chrome + max(list, dialog) + chrome` and never overflows the viewport
1385
1426
  // (issue #3283: an overflowing dialog frame committed the header into
1386
1427
  // scrollback, stranding it above the viewport once the dialog closed).
1387
1428
  #contentSlot: Container;
1388
1429
  #messageContainer: Container;
1389
1430
  #onDelete?: (session: T) => Promise<boolean>;
1431
+ #onRename?: (session: T, title: string) => Promise<string | null>;
1390
1432
  #onRequestRender?: () => void;
1391
1433
  readonly #loadAllSessions?: () => Promise<T[]>;
1392
1434
  #folderSessions: T[];
@@ -1431,6 +1473,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1431
1473
  message: SessionPickerMessage | undefined;
1432
1474
  errorOpen: boolean;
1433
1475
  choice: DeleteChoice<T> | null;
1476
+ rename: OverlayPanel | null;
1434
1477
  node: NativeNode;
1435
1478
  }
1436
1479
  | undefined;
@@ -1439,7 +1482,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1439
1482
  title: string;
1440
1483
  scope: "folder" | "all";
1441
1484
  message: SessionPickerMessage | undefined;
1442
- dialog: HookSelectorComponent | null;
1485
+ dialog: HookSelectorComponent | OverlayPanel | null;
1443
1486
  list: NativeNode;
1444
1487
  node: NativeNode;
1445
1488
  }
@@ -1456,6 +1499,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1456
1499
 
1457
1500
  this.#messageContainer = new Container();
1458
1501
  this.#onDelete = options.onDelete;
1502
+ this.#onRename = options.onRename;
1459
1503
  this.#loadAllSessions = options.loadAllSessions;
1460
1504
  this.#folderSessions = sessions;
1461
1505
  // Storage directory, not recorded cwd: one folder's sessions may record
@@ -1505,6 +1549,11 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1505
1549
  this.#showDeleteConfirmation(session);
1506
1550
  };
1507
1551
  }
1552
+ if (this.#onRename) {
1553
+ this.#sessionList.onRenameRequest = (session: T) => {
1554
+ this.#showRenameDialog(session);
1555
+ };
1556
+ }
1508
1557
  if (this.#loadAllSessions || this.#globalSessions) {
1509
1558
  this.#sessionList.onToggleScope = () => {
1510
1559
  void this.#toggleScope();
@@ -1527,7 +1576,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1527
1576
  * never pays for the cross-project scan.
1528
1577
  */
1529
1578
  async #toggleScope(): Promise<void> {
1530
- if (this.#toggling || this.#confirmationDialog) return;
1579
+ if (this.#toggling || this.#confirmationDialog || this.#renameDialog) return;
1531
1580
  if (this.#scope === "folder") {
1532
1581
  let global = this.#globalSessions;
1533
1582
  if (!global) {
@@ -1651,6 +1700,107 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1651
1700
  this.#onRequestRender?.();
1652
1701
  }
1653
1702
 
1703
+ /**
1704
+ * Mirror a renamed title into the scope caches. The visible list holds
1705
+ * one entry per session, but the folder and all-projects caches can hold
1706
+ * their own copies of the same session, so a rename has to reach all of
1707
+ * them or a scope switch shows the old title again.
1708
+ */
1709
+ #syncScopeTitles(sessionPath: string, title: string): void {
1710
+ for (const cache of [this.#folderSessions, this.#globalSessions]) {
1711
+ if (!cache) continue;
1712
+ const entry = cache.find(s => s.path === sessionPath);
1713
+ if (!entry) continue;
1714
+ entry.title = title;
1715
+ delete (entry as NativeSessionInfo)[kNativeItem];
1716
+ delete (entry as SearchableSessionInfo)[kSearchTextLower];
1717
+ }
1718
+ }
1719
+
1720
+ /** Close the rename dialog without changes, restoring the session list. */
1721
+ #closeRenameDialog(): void {
1722
+ if (!this.#renameDialog) return;
1723
+ this.#renameDialog = null;
1724
+ this.#renameField = null;
1725
+ // Restore the SessionList into the content slot so the picker is back
1726
+ // to its normal layout on the very next render — the same frame the
1727
+ // dialog disappears.
1728
+ this.#contentSlot.clear();
1729
+ this.#contentSlot.addChild(this.#sessionList);
1730
+ this.#onRequestRender?.();
1731
+ }
1732
+
1733
+ #showRenameDialog(session: T): void {
1734
+ if (!this.#onRename || this.#confirmationDialog || this.#renameDialog || this.#toggling) return;
1735
+ // One submit at a time: a second Enter while the rename is still
1736
+ // awaiting disk I/O would queue a second write for the same session.
1737
+ let pending = false;
1738
+ // This editor's own identity: a submit that settles after the user
1739
+ // opened another editor must not close it.
1740
+ const owner = new OverlayPanel("Rename Session");
1741
+ const submit = async (value: string) => {
1742
+ if (pending) return;
1743
+ const title = value.trim();
1744
+ // Empty treated as cancel: Esc and a blank submit both leave the title.
1745
+ if (title.length === 0) {
1746
+ this.#closeRenameDialog();
1747
+ return;
1748
+ }
1749
+ const rename = this.#onRename;
1750
+ if (!rename) {
1751
+ this.#closeRenameDialog();
1752
+ return;
1753
+ }
1754
+ this.#clearError();
1755
+ pending = true;
1756
+ try {
1757
+ const stored = await rename(session, title);
1758
+ if (stored) {
1759
+ // The stored title is the canonical one (the session may
1760
+ // collapse whitespace), so the row and the search index
1761
+ // match what is on disk.
1762
+ this.#sessionList.renameSessionTitle(session.path, stored);
1763
+ this.#syncScopeTitles(session.path, stored);
1764
+ if (this.#preview?.session === session) this.#preview = undefined;
1765
+ } else {
1766
+ this.#showError("Failed to rename session.");
1767
+ }
1768
+ } catch (err) {
1769
+ this.#showError(err instanceof Error ? err.message : String(err));
1770
+ } finally {
1771
+ pending = false;
1772
+ }
1773
+ if (this.#renameDialog === owner) this.#closeRenameDialog();
1774
+ };
1775
+ const field = new TextFormField({
1776
+ theme: formTheme,
1777
+ label: "Rename session",
1778
+ initialValue: session.title ?? "",
1779
+ empty: "cancel",
1780
+ onSubmit: value => {
1781
+ void submit(value);
1782
+ },
1783
+ onCancel: () => {
1784
+ // A cancel while the rename is still in flight must not tear
1785
+ // the editor down underneath it; the submit closes it.
1786
+ if (pending) return;
1787
+ this.#closeRenameDialog();
1788
+ },
1789
+ });
1790
+ field.focused = true;
1791
+ owner.addChild(field);
1792
+ this.#renameDialog = owner;
1793
+ this.#renameField = field;
1794
+ // Swap the SessionList out of the content slot and mount the dialog in its
1795
+ // place: the dialog competes only with the SessionList's rendered budget,
1796
+ // never the SessionList AND the picker chrome, so the picker frame stays
1797
+ // inside the terminal viewport and the TUI never commits the header into
1798
+ // scrollback (issue #3283).
1799
+ this.#contentSlot.clear();
1800
+ this.#contentSlot.addChild(owner);
1801
+ this.#onRequestRender?.();
1802
+ }
1803
+
1654
1804
  /**
1655
1805
  * Render the panel directly so fill-height mode can keep its footer pinned
1656
1806
  * while sharing OverlayPanel's exact rounded-box chrome. Children receive
@@ -1682,7 +1832,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1682
1832
  const cancel = interruptKey();
1683
1833
  const hint = theme.fg(
1684
1834
  "muted",
1685
- `[${this.#onDelete ? `${formatKeyHints(["delete", "backspace"])} delete · ` : ""}${formatKeyHint("enter")} select · ${formatKeyHint("tab")} ${scopeHint} · ${cancel} cancel]`,
1835
+ `[${this.#onDelete ? `${formatKeyHints(["delete", "backspace"])} delete · ` : ""}${this.#onRename ? `${formatKeyHint("f2")} rename · ` : ""}${formatKeyHint("enter")} select · ${formatKeyHint("tab")} ${scopeHint} · ${cancel} cancel]`,
1686
1836
  );
1687
1837
  return [row("", width), row(hint, width), row("", width), bottomBorder(width)];
1688
1838
  }
@@ -1694,24 +1844,32 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1694
1844
 
1695
1845
  /** Over the transcript the picker is its own sheet (`layer`); the standalone app fills the screen surface. */
1696
1846
  nativeSheet(cx: DescribeContext): boolean {
1847
+ // A rename editor is a card with a text field, not a picker sheet: it
1848
+ // renders in place, like the generic composition does.
1849
+ if (this.#renameDialog) return false;
1697
1850
  return cx.supports("picker") && !this.#standalone;
1698
1851
  }
1699
1852
 
1700
1853
  /**
1701
1854
  * With the `picker` kind: the data-first session picker (§4.7). Otherwise
1702
1855
  * scope `tabs`, loading/error message, then the search `input` and session
1703
- * `list` — or the delete-confirmation dialog in their place, as on the ANSI
1704
- * path — and the key-hint footer.
1856
+ * `list` — or the delete-confirmation / rename dialog in their place, as on
1857
+ * the ANSI path — and the key-hint footer.
1705
1858
  */
1706
1859
  override describe(cx: DescribeContext): NativeNode {
1707
- if (cx.supports("picker")) return this.#describePicker();
1860
+ // The picker kind has no text-input affordance, so a rename would
1861
+ // capture every keystroke into an invisible field. While the rename
1862
+ // editor is open, describe the card path instead (the same fallback
1863
+ // the hook selector uses for its slider and countdown).
1864
+ if (cx.supports("picker") && !this.#renameDialog) return this.#describePicker();
1708
1865
  const list = this.#sessionList.describeList();
1866
+ const dialog = this.#confirmationDialog ?? this.#renameDialog;
1709
1867
  const memo = this.#nativeMemo;
1710
1868
  if (
1711
1869
  memo?.title === this.title &&
1712
1870
  memo.scope === this.#scope &&
1713
1871
  memo.message === this.#message &&
1714
- memo.dialog === this.#confirmationDialog &&
1872
+ memo.dialog === dialog &&
1715
1873
  memo.list === list
1716
1874
  ) {
1717
1875
  return memo.node;
@@ -1725,13 +1883,14 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1725
1883
  } else if (message) {
1726
1884
  children.push(node("text", { spans: [span(message.text, "error")] }, undefined, "message"));
1727
1885
  }
1728
- if (this.#confirmationDialog) {
1729
- children.push(this.#confirmationDialog);
1886
+ if (dialog) {
1887
+ children.push(dialog);
1730
1888
  } else {
1731
1889
  children.push(this.#sessionList.searchInput, list);
1732
1890
  }
1733
1891
  const hints: (NativeHint | undefined)[] = [];
1734
1892
  if (this.#onDelete) hints.push({ keys: ["delete", "backspace"], label: "delete" });
1893
+ if (this.#onRename) hints.push({ keys: ["f2"], label: "rename" });
1735
1894
  hints.push({ keys: ["enter"], label: "select" });
1736
1895
  if (this.#sessionList.onToggleScope) {
1737
1896
  hints.push({ keys: ["tab"], label: this.#scope === "all" ? "current folder" : "all projects" });
@@ -1745,7 +1904,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1745
1904
  title: this.title,
1746
1905
  scope: this.#scope,
1747
1906
  message,
1748
- dialog: this.#confirmationDialog,
1907
+ dialog,
1749
1908
  list,
1750
1909
  node: result,
1751
1910
  };
@@ -1756,8 +1915,8 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1756
1915
  * `lg` cards sheet (`screen` for the standalone app): sessions grouped by
1757
1916
  * day (ranked flat while searching), the selected session's preview, the
1758
1917
  * delete confirm strip, and the actions of the keys Enter, Delete/Backspace,
1759
- * Tab (the scope toggle, labelled with the scope it switches to) and Esc.
1760
- * Untitled unless the caller named it; the placeholder says the scope.
1918
+ * F2 (rename), Tab (the scope toggle, labelled with the scope it switches
1919
+ * to) and Esc. Untitled unless the caller named it; the placeholder says the scope.
1761
1920
  */
1762
1921
  #describePicker(): NativeNode {
1763
1922
  const list = this.#sessionList;
@@ -1766,6 +1925,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1766
1925
  const message = this.#message;
1767
1926
  const errorOpen = this.#pickerErrorOpen && message?.kind === "error";
1768
1927
  const choice = this.#deleteChoice;
1928
+ const rename = this.#renameDialog;
1769
1929
  const memo = this.#pickerMemo;
1770
1930
  if (
1771
1931
  memo?.view === view &&
@@ -1773,7 +1933,8 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1773
1933
  memo.scope === this.#scope &&
1774
1934
  memo.message === message &&
1775
1935
  memo.errorOpen === errorOpen &&
1776
- memo.choice === choice
1936
+ memo.choice === choice &&
1937
+ memo.rename === rename
1777
1938
  ) {
1778
1939
  return memo.node;
1779
1940
  }
@@ -1781,6 +1942,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1781
1942
  const toggle = list.onToggleScope !== undefined;
1782
1943
  const actions = [pickerAction("resume", this.#actionLabel, "enter", { primary: true })];
1783
1944
  if (this.#onDelete) actions.push(pickerAction("delete", "Delete", "backspace"));
1945
+ if (this.#onRename) actions.push(pickerAction("rename", "Rename", "f2"));
1784
1946
  if (toggle) {
1785
1947
  actions.push(pickerAction("scope", this.#scope === "all" ? "This folder" : "All projects", "tab"));
1786
1948
  }
@@ -1831,7 +1993,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1831
1993
  },
1832
1994
  preview,
1833
1995
  );
1834
- this.#pickerMemo = { view, preview, scope: this.#scope, message, errorOpen, choice, node: result };
1996
+ this.#pickerMemo = { view, preview, scope: this.#scope, message, errorOpen, choice, rename, node: result };
1835
1997
  return result;
1836
1998
  }
1837
1999
 
@@ -1867,27 +2029,31 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1867
2029
 
1868
2030
  /**
1869
2031
  * Picker pointer events run the keys' paths: a row click selects (arrows),
1870
- * a second click or `Resume` resumes (Enter), `Delete` asks (Delete), the
1871
- * tab or `All projects` toggles scope (Tab), `Close` cancels (Esc), and
1872
- * the confirm strip answers the delete dialog (Yes / Esc).
2032
+ * a second click or `Resume` resumes (Enter), `Delete` asks (Delete),
2033
+ * `Rename` renames (F2), the tab or `All projects` toggles scope (Tab),
2034
+ * `Close` cancels (Esc), and the confirm strip answers the delete dialog (Yes / Esc).
1873
2035
  */
1874
2036
  #handlePickerEvent(ev: PickerEvent): void {
1875
2037
  const list = this.#sessionList;
1876
2038
  const choice = this.#deleteChoice;
1877
2039
  if (ev.kind !== "action") {
1878
- if (choice) return;
2040
+ if (choice || this.#renameDialog) return;
1879
2041
  if (ev.kind === "select") list.selectSession(ev.item);
1880
2042
  else list.confirmSession(ev.item);
1881
2043
  return;
1882
2044
  }
1883
2045
  if (ev.act === "delete-confirm") choice?.confirm();
1884
- else if (ev.act === "cancel") choice?.cancel();
1885
- else if (ev.act === "close") {
2046
+ else if (ev.act === "cancel") {
2047
+ if (choice) choice.cancel();
2048
+ else this.#closeRenameDialog();
2049
+ } else if (ev.act === "close") {
1886
2050
  if (choice) choice.cancel();
2051
+ else if (this.#renameDialog) this.#closeRenameDialog();
1887
2052
  else list.onCancel?.();
1888
- } else if (choice) return;
2053
+ } else if (choice || this.#renameDialog) return;
1889
2054
  else if (ev.act === "resume") list.resumeSelected();
1890
2055
  else if (ev.act === "delete") list.requestDelete();
2056
+ else if (ev.act === "rename") list.requestRename();
1891
2057
  else if (ev.act === "scope") {
1892
2058
  list.onToggleScope?.();
1893
2059
  } else if (ev.act === "clear") list.clearSearch();
@@ -1907,7 +2073,8 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1907
2073
  if (event.type === "select" && event.item !== this.#scope) void this.#toggleScope();
1908
2074
  return;
1909
2075
  }
1910
- if (event.key === "list" && !this.#confirmationDialog) this.#sessionList.confirmSession(event.item);
2076
+ if (event.key === "list" && !this.#confirmationDialog && !this.#renameDialog)
2077
+ this.#sessionList.confirmSession(event.item);
1911
2078
  }
1912
2079
 
1913
2080
  handleInput(keyData: string): void {
@@ -1919,6 +2086,8 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1919
2086
  }
1920
2087
  if (this.#confirmationDialog) {
1921
2088
  this.#confirmationDialog.handleInput(keyData);
2089
+ } else if (this.#renameDialog) {
2090
+ this.#renameField?.handleInput(keyData);
1922
2091
  } else {
1923
2092
  this.#sessionList.handleInput(keyData);
1924
2093
  }
@@ -1931,7 +2100,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1931
2100
  * pointer. Mouse is inert while the delete-confirmation dialog is open.
1932
2101
  */
1933
2102
  #handleMouse(data: string): void {
1934
- if (this.#confirmationDialog) return;
2103
+ if (this.#confirmationDialog || this.#renameDialog) return;
1935
2104
  routeSgrMouseInput(data, event => {
1936
2105
  if (event.wheel !== null) {
1937
2106
  this.#sessionList.handleWheel(event.wheel);
@@ -435,22 +435,15 @@ function clockTime(date: Date): string {
435
435
  }
436
436
 
437
437
  /**
438
- * When a window resets, relative to `nowMs`: `resets 18:00` today, `resets
439
- * Tue 18:00` within the week, `resets 3 Oct` beyond; the title carries the
440
- * absolute date and the countdown.
438
+ * When a window resets, `resetMs` from `nowMs`: the countdown (`resets in
439
+ * 2h30m`), which reads the same in every time zone; the title carries the
440
+ * local date and time.
441
441
  */
442
442
  function resetLabel(nowMs: number, resetMs: number): { text: string; title: string } {
443
443
  const at = new Date(nowMs + resetMs);
444
- const now = new Date(nowMs);
445
- const sameDay = at.toDateString() === now.toDateString();
446
- const text = sameDay
447
- ? `resets ${clockTime(at)}`
448
- : resetMs < 6 * 86_400_000
449
- ? `resets ${WEEKDAY_NAMES[at.getDay()]} ${clockTime(at)}`
450
- : `resets ${at.getDate()} ${MONTH_NAMES[at.getMonth()]}`;
451
444
  return {
452
- text,
453
- title: `Resets ${shortDate(at)} ${at.getFullYear()}, ${clockTime(at)} (in ${formatDuration(resetMs)})`,
445
+ text: `resets in ${formatDuration(resetMs)}`,
446
+ title: `Resets ${shortDate(at)} ${at.getFullYear()}, ${clockTime(at)}`,
454
447
  };
455
448
  }
456
449
 
@@ -1063,7 +1063,12 @@ export class CustomEditor extends Editor {
1063
1063
  /** Editing is available during bootstrap; atomic sends wait until submission is wired and enabled
1064
1064
  * and no dictation is still writing into the draft. */
1065
1065
  protected override get nativeSendable(): boolean {
1066
- return this.onSubmit !== undefined && !this.disableSubmit && !dictationHoldsDraft(this.composerState().dictation);
1066
+ return (
1067
+ !this.nativeKeyPassthrough &&
1068
+ this.onSubmit !== undefined &&
1069
+ !this.disableSubmit &&
1070
+ !dictationHoldsDraft(this.composerState().dictation)
1071
+ );
1067
1072
  }
1068
1073
  /** The quoted-title placeholder, kept while the title stands so the editor node stays reused. */
1069
1074
  #titlePlaceholder: { title: string; text: TspText } | undefined;
package/src/terminal.ts CHANGED
@@ -774,7 +774,8 @@ type Da1SentinelOwner =
774
774
  | { kind: "privateMode"; mode: number }
775
775
  | { kind: "osc99Probe"; id: string }
776
776
  | { kind: "glyphProtocol"; phase: "support" | "confirm" }
777
- | { kind: "tsp" };
777
+ | { kind: "tsp" }
778
+ | { kind: "drain"; answered: () => void };
778
779
 
779
780
  let nextOsc99ProbeId = 1;
780
781
 
@@ -1607,6 +1608,10 @@ export class ProcessTerminal implements Terminal {
1607
1608
  if (owner.phase === this.#glyphProtocolPhase) this.#resolveGlyphProtocolSupport(false);
1608
1609
  break;
1609
1610
  }
1611
+ case "drain": {
1612
+ owner.answered();
1613
+ break;
1614
+ }
1610
1615
  case "tsp": {
1611
1616
  // DA1 before any `tsp;r` reply: the terminal doesn't speak TSP.
1612
1617
  this.#resolveTspSupport(
@@ -2314,12 +2319,29 @@ export class ProcessTerminal implements Terminal {
2314
2319
  process.stdin.on("data", onData);
2315
2320
  const endTime = Date.now() + maxMs;
2316
2321
 
2322
+ // A terminal that answers DA1 (`#sixelSupport` is set from its first
2323
+ // reply) answers this one after everything it sent before reading it:
2324
+ // replies to output already written (TSP acks for the last frames, which
2325
+ // a terminal may send well after the idle window) are read here instead
2326
+ // of reaching the shell as typed text.
2327
+ let sentinelAnswered = true;
2328
+ if (this.#sixelSupport !== undefined && !this.#dead) {
2329
+ sentinelAnswered = false;
2330
+ this.#da1SentinelOwners.push({
2331
+ kind: "drain",
2332
+ answered: () => {
2333
+ sentinelAnswered = true;
2334
+ },
2335
+ });
2336
+ this.#safeWrite("\x1b[c");
2337
+ }
2338
+
2317
2339
  try {
2318
2340
  while (true) {
2319
2341
  const now = Date.now();
2320
2342
  const timeLeft = endTime - now;
2321
2343
  if (timeLeft <= 0) break;
2322
- if (now - lastDataTime >= idleMs) break;
2344
+ if (sentinelAnswered && now - lastDataTime >= idleMs) break;
2323
2345
  await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
2324
2346
  }
2325
2347
  } finally {
@@ -843,71 +843,6 @@ export async function getResolvedThemeColors(themeName?: string): Promise<Record
843
843
  return cssColors;
844
844
  }
845
845
 
846
- /** One appearance variant of {@link NativeThemePalette}: token name → `#rrggbb`. */
847
- export type NativeThemeVariant = Record<string, string>;
848
-
849
- /** omp's resolved theme for a Tern Surface Protocol terminal (the `t` verb body minus `sf`). */
850
- export interface NativeThemePalette {
851
- dark?: NativeThemeVariant;
852
- light?: NativeThemeVariant;
853
- name: { dark?: string; light?: string };
854
- }
855
-
856
- /**
857
- * Cheap identity of everything {@link getNativeThemePalette} depends on;
858
- * recompute the palette only when it changes.
859
- */
860
- export function getNativeThemePaletteKey(): string {
861
- return `${themeEpoch}|${autoDetectedTheme}|${autoDarkTheme}|${autoLightTheme}|${currentThemeName}|${previewThemeName}|${currentColorBlindMode}`;
862
- }
863
-
864
- /** Every token of theme `name` as hex (terminal-default tokens omitted), plus its export colours. */
865
- function nativeThemeVariant(name: string): { light: boolean; colors: NativeThemeVariant } {
866
- const themeJson = loadThemeJsonSync(name);
867
- const built = createTheme(themeJson, getCurrentThemeOptions());
868
- const raw = resolveThemeColors(themeJson.colors, themeJson.vars);
869
- const colors: NativeThemeVariant = {};
870
- for (const key in raw) {
871
- // Terminal-default tokens stay unset; keys outside the schema are ignored like the theme does.
872
- if (raw[key as keyof typeof raw] === "") continue;
873
- if (isValidThemeColor(key)) colors[key] = built.getColorHex(key);
874
- else if (isValidThemeBg(key)) colors[key] = built.getBgHex(key);
875
- }
876
- const exported = resolveThemeExportColors(themeJson);
877
- for (const key of ["pageBg", "cardBg", "infoBg"] as const) {
878
- const value = exported[key];
879
- if (value) colors[key] = value;
880
- }
881
- return { light: isLightThemeJson(themeJson), colors };
882
- }
883
-
884
- /**
885
- * omp's theme for a native surface: with auto theme, the configured dark and
886
- * light themes; otherwise (or while the theme selector previews one) the
887
- * active theme under its own appearance only. Themes without a loadable
888
- * definition (in-memory instances) contribute nothing.
889
- */
890
- export function getNativeThemePalette(): NativeThemePalette {
891
- const palette: NativeThemePalette = { name: {} };
892
- const add = (name: string, as?: "dark" | "light"): void => {
893
- try {
894
- const variant = nativeThemeVariant(name);
895
- const slot = as ?? (variant.light ? "light" : "dark");
896
- palette[slot] = variant.colors;
897
- palette.name[slot] = name;
898
- } catch (error) {
899
- logger.debug("Native theme palette: theme not loadable", { name, error: String(error) });
900
- }
901
- };
902
- const single = previewThemeName ?? (autoDetectedTheme ? undefined : currentThemeName);
903
- if (single !== undefined) add(single);
904
- else {
905
- add(autoDarkTheme, "dark");
906
- add(autoLightTheme, "light");
907
- }
908
- return palette;
909
- }
910
-
911
846
  /**
912
847
  * Check if a theme is a "light" theme by analyzing its status-line background
913
848
  * luminance. Loads theme JSON synchronously (built-in or custom file on disk)
@@ -25,7 +25,12 @@ const NATIVE_SHEET_INSET_COLS = 8;
25
25
  // Action id of the native Force kill button (mirrors Esc).
26
26
  const KILL_ACTION = "kill";
27
27
 
28
- function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
28
+ /**
29
+ * Translates a TUI key event into the bytes a terminal would send to a PTY
30
+ * child; key releases encode to "". Shared by the agent `pty` console and
31
+ * `!` commands (`BashExecutionComponent.encodePtyInput`).
32
+ */
33
+ export function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
29
34
  const kitty = parseKittySequence(data);
30
35
  if (kitty?.eventType === 3) {
31
36
  return "";
@@ -190,10 +190,22 @@ function headlineGlance(view: JobWidgetView): JobWidgetGlance | undefined {
190
190
  };
191
191
  }
192
192
 
193
+ /** The meter's `140/190` when the headline only restates its numerator (`140`). */
194
+ function meterWhole(headline: JobWidgetGlance, view: JobWidgetView): string | undefined {
195
+ const meter = view.blocks.find((block): block is JobWidgetMeterBlock => block.kind === "meter");
196
+ const whole = meter ? plainLine(meter.text) : "";
197
+ return /^\s*\/\s*\S/.test(whole.slice(headline.text.trim().length)) && whole.startsWith(headline.text.trim())
198
+ ? whole
199
+ : undefined;
200
+ }
201
+
193
202
  /** What one glance at the widget reads: the headline, else the latest figure of its first spark or meter. */
194
203
  export function jobWidgetGlance(view: JobWidgetView): JobWidgetGlance | undefined {
195
204
  const headline = headlineGlance(view);
196
- if (headline) return headline;
205
+ if (headline) {
206
+ const whole = meterWhole(headline, view);
207
+ return whole ? { ...headline, text: whole } : headline;
208
+ }
197
209
  for (const block of view.blocks) {
198
210
  if (block.kind === "spark" && block.values.length > 0 && plainLine(block.last)) {
199
211
  return { text: plainLine(block.last), label: plainLine(block.label) };
@@ -83,7 +83,7 @@ export function formatArgsInline(
83
83
  const pairs: string[] = [];
84
84
  let length = 0;
85
85
  for (const key in args) {
86
- if (!Object.hasOwn(args, key)) continue;
86
+ if (!Object.hasOwn(args, key) || key in HIDDEN_ARG_KEYS) continue;
87
87
  const pair = `${options.sanitizeText?.(key) ?? key}=${formatScalar(args[key], 24, options)}`;
88
88
  const added = pair.length + (pairs.length > 0 ? 2 : 0);
89
89
  if (length + added > maxWidth && pairs.length > 0) {
package/src/tui.ts CHANGED
@@ -2450,8 +2450,13 @@ export class TUI extends Container {
2450
2450
  }
2451
2451
  }
2452
2452
 
2453
- addInputListener(listener: InputListener): () => void {
2454
- this.#inputListeners.add(listener);
2453
+ /** Registers a key listener; `prepend` gives a foreground input owner first refusal over global shortcuts. */
2454
+ addInputListener(listener: InputListener, options?: { prepend?: boolean }): () => void {
2455
+ if (options?.prepend) {
2456
+ this.#inputListeners = new Set([listener, ...this.#inputListeners]);
2457
+ } else {
2458
+ this.#inputListeners.add(listener);
2459
+ }
2455
2460
  return () => {
2456
2461
  this.#inputListeners.delete(listener);
2457
2462
  };