@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.
- package/CHANGELOG.md +24 -14
- package/dist/types/apps/session-picker.d.ts +4 -0
- package/dist/types/chat/assistant-message.d.ts +1 -1
- package/dist/types/chat/bash-execution.d.ts +2 -0
- package/dist/types/chat/display-preferences.d.ts +2 -0
- package/dist/types/chat/tool-execution.d.ts +1 -1
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/overlays/annotation-overlay.d.ts +7 -1
- package/dist/types/overlays/annotation-types.d.ts +28 -0
- package/dist/types/overlays/plan-review-overlay.d.ts +2 -0
- package/dist/types/overlays/session-selector.d.ts +13 -2
- package/dist/types/theme/theme.d.ts +0 -23
- package/dist/types/tools/bash-interactive.d.ts +6 -0
- package/dist/types/tui.d.ts +4 -1
- package/package.json +10 -10
- package/src/apps/session-picker.ts +5 -0
- package/src/chat/advisor-message.ts +2 -0
- package/src/chat/assistant-message.ts +3 -1
- package/src/chat/bash-execution.ts +6 -0
- package/src/chat/display-preferences.ts +3 -0
- package/src/chat/tool-execution.ts +7 -3
- package/src/components/editor.ts +3 -1
- package/src/native/backend.ts +2 -64
- package/src/overlays/annotation-overlay.ts +125 -37
- package/src/overlays/annotation-types.ts +24 -0
- package/src/overlays/hook-selector.ts +2 -2
- package/src/overlays/plan-review-overlay.ts +13 -2
- package/src/overlays/session-selector.ts +195 -26
- package/src/overlays/usage-dashboard.ts +5 -12
- package/src/prompt/custom-editor.ts +6 -1
- package/src/terminal.ts +24 -2
- package/src/theme/theme.ts +0 -65
- package/src/tools/bash-interactive.ts +6 -1
- package/src/tools/job-widget.ts +13 -1
- package/src/tools/json-tree.ts +1 -1
- 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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
|
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
|
-
|
|
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 ===
|
|
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 (
|
|
1729
|
-
children.push(
|
|
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
|
|
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
|
|
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),
|
|
1871
|
-
* tab or `All projects` toggles scope (Tab),
|
|
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")
|
|
1885
|
-
|
|
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
|
|
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,
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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)}
|
|
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
|
|
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 {
|
package/src/theme/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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 "";
|
package/src/tools/job-widget.ts
CHANGED
|
@@ -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)
|
|
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) };
|
package/src/tools/json-tree.ts
CHANGED
|
@@ -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
|
-
|
|
2454
|
-
|
|
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
|
};
|