@linxiraos/pi-tui 1.3.1 → 1.4.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/CHANGELOG.md +184 -176
- package/dist/types/apps/session-picker.d.ts +13 -1
- package/dist/types/overlays/resume-picker.d.ts +293 -0
- package/dist/types/overlays/resume-tree.d.ts +353 -0
- package/dist/types/overlays/session-selector.d.ts +22 -4
- package/dist/types/terminal-capabilities.d.ts +1 -1
- package/dist/types/tools/hub.d.ts +3 -1
- package/dist/types/tools/irc.d.ts +5 -1
- package/dist/types/tools/wait.d.ts +6 -0
- package/package.json +9 -9
- package/src/apps/session-picker.ts +20 -2
- package/src/overlays/resume-picker.ts +706 -0
- package/src/overlays/resume-tree.ts +1901 -0
- package/src/overlays/session-selector.ts +579 -39
- package/src/terminal-capabilities.d.ts +1 -1
- package/src/terminal-capabilities.ts +11 -0
- package/src/tools/hub.ts +4 -1
- package/src/tools/irc.d.ts +5 -1
- package/src/tools/irc.ts +5 -1
- package/src/tools/proc-render.ts +1 -0
- package/src/tools/wait.d.ts +6 -0
- package/src/tools/wait.ts +6 -0
|
@@ -29,6 +29,19 @@ import {
|
|
|
29
29
|
import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
|
|
30
30
|
import { hintsRow, type NativeHint, overlayCard } from "../native/overlay";
|
|
31
31
|
import { plainText } from "../native/spans";
|
|
32
|
+
import { tuiText, tuiTextFmt } from "../i18n";
|
|
33
|
+
import {
|
|
34
|
+
buildResumePickerModel,
|
|
35
|
+
nextResumeScope,
|
|
36
|
+
type ResumeDirectoryLike,
|
|
37
|
+
type ResumePickerModel,
|
|
38
|
+
type ResumeScopeInputLike,
|
|
39
|
+
type ResumeScopeKindLike,
|
|
40
|
+
type ResumeSourceLike,
|
|
41
|
+
resumeConversationContext,
|
|
42
|
+
resumeConversationPreview,
|
|
43
|
+
resumeScopeLabel,
|
|
44
|
+
} from "./resume-picker";
|
|
32
45
|
import { theme } from "../theme/theme";
|
|
33
46
|
import { contentRowWidth } from "../chrome/selector-helpers";
|
|
34
47
|
import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
|
|
@@ -52,6 +65,20 @@ export interface SessionSelectorEntry {
|
|
|
52
65
|
}
|
|
53
66
|
import { shortenPath } from "../render/render-utils";
|
|
54
67
|
import { HookSelectorComponent } from "./hook-selector";
|
|
68
|
+
import { ResumeTreeComponent, type ResumeTreeSource } from "./resume-tree";
|
|
69
|
+
|
|
70
|
+
export type {
|
|
71
|
+
ResumeConversationLike,
|
|
72
|
+
ResumeDirectoryLike,
|
|
73
|
+
ResumeProjectLike,
|
|
74
|
+
ResumeQueryLike,
|
|
75
|
+
ResumeScopeInputLike,
|
|
76
|
+
ResumeScopeKindLike,
|
|
77
|
+
ResumeSourceLike,
|
|
78
|
+
ResumeViewLike,
|
|
79
|
+
ResumeViewRowLike,
|
|
80
|
+
ResumeWorkspaceLike,
|
|
81
|
+
} from "./resume-picker";
|
|
55
82
|
import { bottomBorder, OverlayPanel, row, topBorder } from "../chrome/overlay-box";
|
|
56
83
|
import { MenuSelection, getMenuWindow } from "../components/menu-selection";
|
|
57
84
|
import { formatKeyHint, formatKeyHints } from "../app-keybindings";
|
|
@@ -563,6 +590,12 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
563
590
|
| undefined;
|
|
564
591
|
/** Bumped when the session set itself changes (scope switch, delete): the picker catalogue's memo key. */
|
|
565
592
|
#datasetVersion = 0;
|
|
593
|
+
/**
|
|
594
|
+
* Resume-model mode: the component owns the visible order (built from the
|
|
595
|
+
* resume view), so this list only mirrors it and search filtering happens
|
|
596
|
+
* in the model instead of here.
|
|
597
|
+
*/
|
|
598
|
+
#resumeDriven = false;
|
|
566
599
|
/** The picker catalogue (every session in scope), rebuilt only when the set, markers or minute change. */
|
|
567
600
|
#pickerCatalogue: SessionCatalogue | undefined;
|
|
568
601
|
/** Paths upserted through `itemsAdd` (history badges) since the catalogue was last sent. */
|
|
@@ -643,6 +676,26 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
643
676
|
this.#selectCurrentSession();
|
|
644
677
|
}
|
|
645
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Resume-model mode: the given sessions are already the model's ordered,
|
|
681
|
+
* filtered view, so mirror them and keep the selection when it survives.
|
|
682
|
+
*/
|
|
683
|
+
syncSessions(sessions: readonly T[], preferredPath?: string | null): void {
|
|
684
|
+
this.#resumeDriven = true;
|
|
685
|
+
if (this.#menu.visibleItems === sessions && (preferredPath == null || this.#menu.selectedKey === preferredPath)) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const selected = preferredPath ?? this.#menu.selectedKey;
|
|
689
|
+
const keep = selected && sessions.some(session => session.path === selected) ? selected : sessions[0]?.path;
|
|
690
|
+
this.#menu.setItems(sessions, keep);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Search query changed while resume-driven: the model rebuilds the rows, not this list. */
|
|
694
|
+
#resumeQueryChanged(): void {
|
|
695
|
+
this.#itemsVersion++;
|
|
696
|
+
this.onRequestRender?.();
|
|
697
|
+
}
|
|
698
|
+
|
|
646
699
|
#filterSessions(query: string): void {
|
|
647
700
|
this.#scanGeneration++;
|
|
648
701
|
this.#itemsVersion++;
|
|
@@ -799,9 +852,16 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
799
852
|
|
|
800
853
|
removeSession(sessionPath: string): void {
|
|
801
854
|
const index = this.#allSessions.findIndex(s => s.path === sessionPath);
|
|
802
|
-
if (index
|
|
803
|
-
|
|
804
|
-
|
|
855
|
+
if (index !== -1) {
|
|
856
|
+
this.#datasetVersion++;
|
|
857
|
+
this.#allSessions.splice(index, 1);
|
|
858
|
+
}
|
|
859
|
+
if (this.#resumeDriven) {
|
|
860
|
+
// The host owns the resume view; the picker hides the path itself.
|
|
861
|
+
const remain = this.#menu.visibleItems.filter(session => session.path !== sessionPath);
|
|
862
|
+
this.#menu.setItems(remain, remain[0]?.path);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
805
865
|
// Re-filter to update the composed result set
|
|
806
866
|
this.#filterSessions(this.#searchInput.getValue());
|
|
807
867
|
// Adjust selection if we deleted the last item or beyond
|
|
@@ -856,6 +916,10 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
856
916
|
/** Empty the search query (the empty state's "Clear search"). */
|
|
857
917
|
clearSearch(): void {
|
|
858
918
|
this.#searchInput.setValue("");
|
|
919
|
+
if (this.#resumeDriven) {
|
|
920
|
+
this.#resumeQueryChanged();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
859
923
|
this.#filterSessions("");
|
|
860
924
|
}
|
|
861
925
|
|
|
@@ -1130,17 +1194,7 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
1130
1194
|
lines.push(""); // Blank line after search
|
|
1131
1195
|
|
|
1132
1196
|
if (this.#menu.visibleItems.length === 0) {
|
|
1133
|
-
|
|
1134
|
-
lines.push(truncateToWidth(theme.fg("muted", "No sessions found"), width));
|
|
1135
|
-
} else {
|
|
1136
|
-
// "Current folder" scope - hint to try "all"
|
|
1137
|
-
lines.push(
|
|
1138
|
-
truncateToWidth(
|
|
1139
|
-
theme.fg("muted", `No sessions in current folder. Press ${formatKeyHint("tab")} to view all.`),
|
|
1140
|
-
width,
|
|
1141
|
-
),
|
|
1142
|
-
);
|
|
1143
|
-
}
|
|
1197
|
+
lines.push(truncateToWidth(theme.fg("muted", "No sessions found"), width));
|
|
1144
1198
|
return lines;
|
|
1145
1199
|
}
|
|
1146
1200
|
|
|
@@ -1325,7 +1379,8 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
1325
1379
|
}
|
|
1326
1380
|
// Pass everything else to search input
|
|
1327
1381
|
this.#searchInput.handleInput(keyData);
|
|
1328
|
-
this.#
|
|
1382
|
+
if (this.#resumeDriven) this.#resumeQueryChanged();
|
|
1383
|
+
else this.#filterSessions(this.#searchInput.getValue());
|
|
1329
1384
|
}
|
|
1330
1385
|
}
|
|
1331
1386
|
|
|
@@ -1336,6 +1391,17 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
|
|
|
1336
1391
|
loadAllSessions?: () => Promise<T[]>;
|
|
1337
1392
|
/** Preloaded all-projects list; cached so the first Tab toggle is instant. */
|
|
1338
1393
|
allSessions?: T[];
|
|
1394
|
+
/**
|
|
1395
|
+
* The host's resume model (project → workspace → conversation) and its
|
|
1396
|
+
* pure view builder. When set, the native `picker` describes the resume
|
|
1397
|
+
* tree instead of the flat day-grouped list; the ANSI path is unchanged.
|
|
1398
|
+
*/
|
|
1399
|
+
resumeSource?: ResumeSourceLike;
|
|
1400
|
+
/**
|
|
1401
|
+
* What delete does to the session file: an in-session picker moves it to
|
|
1402
|
+
* the trash, `--resume` deletes it permanently. Wording only.
|
|
1403
|
+
*/
|
|
1404
|
+
deleteMode?: "trash" | "permanent";
|
|
1339
1405
|
/** Picker heading; defaults to "Resume Session". */
|
|
1340
1406
|
title?: string;
|
|
1341
1407
|
/** Primary action label; defaults to "Resume" (ANSI footer defaults to "select"). */
|
|
@@ -1373,10 +1439,53 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
|
|
|
1373
1439
|
}
|
|
1374
1440
|
|
|
1375
1441
|
/**
|
|
1376
|
-
*
|
|
1442
|
+
* One footer line: key hints left, mouse hints right. Hints drop from the end
|
|
1443
|
+
* of the key list first, then the mouse list truncates, so the line always
|
|
1444
|
+
* fits the panel's inner width.
|
|
1377
1445
|
*/
|
|
1446
|
+
function composeFooter(width: number, keys: readonly string[], mouse: readonly string[]): string {
|
|
1447
|
+
const contentWidth = Math.max(1, width - 4);
|
|
1448
|
+
const rightText = mouse.join(` ${theme.sep.dot} `);
|
|
1449
|
+
const right = theme.fg("dim", `[${rightText}]`);
|
|
1450
|
+
const rightWidth = visibleWidth(right);
|
|
1451
|
+
const kept = [...keys];
|
|
1452
|
+
const leftText = () => theme.fg("muted", `[${kept.join(` ${theme.sep.dot} `)}]`);
|
|
1453
|
+
const gap = 2;
|
|
1454
|
+
while (kept.length > 0 && visibleWidth(leftText()) + gap + rightWidth > contentWidth) kept.pop();
|
|
1455
|
+
const left = kept.length > 0 ? leftText() : "";
|
|
1456
|
+
if (!left)
|
|
1457
|
+
return truncateToWidth(
|
|
1458
|
+
rightWidth > contentWidth ? right : `${" ".repeat(Math.max(0, contentWidth - rightWidth))}${right}`,
|
|
1459
|
+
contentWidth,
|
|
1460
|
+
);
|
|
1461
|
+
const room = contentWidth - visibleWidth(left) - gap;
|
|
1462
|
+
if (room < rightWidth) {
|
|
1463
|
+
const truncated = truncateToWidth(right, Math.max(0, room));
|
|
1464
|
+
return truncateToWidth(left + " ".repeat(gap) + truncated, contentWidth);
|
|
1465
|
+
}
|
|
1466
|
+
return left + " ".repeat(contentWidth - visibleWidth(left) - rightWidth) + right;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** Component that renders a session selector with optional confirmation dialog. */
|
|
1378
1470
|
export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSelectorEntry> extends OverlayPanel {
|
|
1379
1471
|
#sessionList: SessionList<T>;
|
|
1472
|
+
/**
|
|
1473
|
+
* ANSI three-level resume tree. Mounted lazily on the first ANSI frame: the
|
|
1474
|
+
* native picker owns its own event model, so mounting the tree up front
|
|
1475
|
+
* would swallow the raw keys the native path never sends through here.
|
|
1476
|
+
*/
|
|
1477
|
+
#resumeTree: ResumeTreeComponent | undefined;
|
|
1478
|
+
#resumeHandlers:
|
|
1479
|
+
| {
|
|
1480
|
+
onSelect: (session: T) => void;
|
|
1481
|
+
onCancel: () => void;
|
|
1482
|
+
onExit: () => void;
|
|
1483
|
+
}
|
|
1484
|
+
| undefined;
|
|
1485
|
+
#resumeTreeSource: ResumeTreeSource | undefined;
|
|
1486
|
+
/** True once the terminal answered with native `picker` support. */
|
|
1487
|
+
#nativePicker = false;
|
|
1488
|
+
readonly #getTerminalRowsOption: (() => number) | undefined;
|
|
1380
1489
|
#confirmationDialog: HookSelectorComponent | null = null;
|
|
1381
1490
|
// Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
|
|
1382
1491
|
// frame. The delete dialog REPLACES the list in this slot rather than being
|
|
@@ -1414,6 +1523,43 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1414
1523
|
/** The native picker shows `#message`'s error until the next key or pointer event. */
|
|
1415
1524
|
#pickerErrorOpen = false;
|
|
1416
1525
|
readonly #standalone: boolean;
|
|
1526
|
+
/** The host's resume model; absent keeps the flat day-grouped picker. */
|
|
1527
|
+
readonly #resumeSource?: ResumeSourceLike;
|
|
1528
|
+
/** The live session's path, used to resolve unfixed resume scopes and mark `current`. */
|
|
1529
|
+
readonly #getCurrentSessionPath: () => string | undefined;
|
|
1530
|
+
/** Delete wording: in-session moves to the trash, `--resume` deletes permanently. */
|
|
1531
|
+
readonly #deleteMode: "trash" | "permanent";
|
|
1532
|
+
/** The active resume scope (workspace → project → all); expanded keys pin the current group. */
|
|
1533
|
+
#resumeScope: ResumeScopeInputLike;
|
|
1534
|
+
/** Conversations deleted in this view; the directory is host-owned and not rebuilt here. */
|
|
1535
|
+
readonly #resumeHidden = new Set<string>();
|
|
1536
|
+
#resumeModelMemo:
|
|
1537
|
+
| {
|
|
1538
|
+
source: ResumeSourceLike;
|
|
1539
|
+
directory: ResumeDirectoryLike | null;
|
|
1540
|
+
query: string;
|
|
1541
|
+
scopeKey: string;
|
|
1542
|
+
currentPath: string | undefined;
|
|
1543
|
+
hidden: number;
|
|
1544
|
+
showCwd: boolean;
|
|
1545
|
+
model: ResumePickerModel | undefined;
|
|
1546
|
+
}
|
|
1547
|
+
| undefined;
|
|
1548
|
+
#resumeSessionsMemo: { model: ResumePickerModel; sessions: readonly T[] } | undefined;
|
|
1549
|
+
#resumeLoadingMemo:
|
|
1550
|
+
| { source: ResumeSourceLike; error: string | undefined; query: string; cursor: number; node: NativeNode }
|
|
1551
|
+
| undefined;
|
|
1552
|
+
#resumePickerMemo:
|
|
1553
|
+
| {
|
|
1554
|
+
model: ResumePickerModel;
|
|
1555
|
+
preview: readonly NativeChild[];
|
|
1556
|
+
choice: DeleteChoice<T> | null;
|
|
1557
|
+
errorOpen: boolean;
|
|
1558
|
+
message: SessionPickerMessage | undefined;
|
|
1559
|
+
selected: string | null;
|
|
1560
|
+
node: NativeNode;
|
|
1561
|
+
}
|
|
1562
|
+
| undefined;
|
|
1417
1563
|
/** A caller's heading for the picker head; the default `/resume` picker has none (the search names it). */
|
|
1418
1564
|
readonly #pickerTitle: string | undefined;
|
|
1419
1565
|
/** The open delete confirmation's two answers, for the picker's confirm strip. */
|
|
@@ -1452,7 +1598,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1452
1598
|
onExit: () => void,
|
|
1453
1599
|
options: SessionSelectorOptions<T> = {},
|
|
1454
1600
|
) {
|
|
1455
|
-
super(options.title ?? "Resume Session");
|
|
1601
|
+
super(options.title ?? tuiText("ssTitleResume", "Resume Session"));
|
|
1456
1602
|
|
|
1457
1603
|
this.#messageContainer = new Container();
|
|
1458
1604
|
this.#onDelete = options.onDelete;
|
|
@@ -1464,10 +1610,16 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1464
1610
|
this.#globalSessions = options.allSessions ?? null;
|
|
1465
1611
|
this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
|
|
1466
1612
|
this.#fillHeight = options.fillHeight ?? false;
|
|
1467
|
-
this.#title = options.title ?? "Resume Session";
|
|
1613
|
+
this.#title = options.title ?? tuiText("ssTitleResume", "Resume Session");
|
|
1468
1614
|
this.#actionLabel = options.actionLabel ?? "Resume";
|
|
1469
1615
|
this.#pickerTitle = options.title;
|
|
1470
1616
|
this.#standalone = options.standalone ?? false;
|
|
1617
|
+
this.#resumeSource = options.resumeSource;
|
|
1618
|
+
this.#resumeScope = options.resumeSource?.initialScope ?? "workspace";
|
|
1619
|
+
this.#deleteMode = options.deleteMode ?? options.resumeSource?.deleteMode ?? "permanent";
|
|
1620
|
+
const currentSessionPath = options.currentSessionPath;
|
|
1621
|
+
this.#getCurrentSessionPath =
|
|
1622
|
+
typeof currentSessionPath === "function" ? currentSessionPath : () => currentSessionPath;
|
|
1471
1623
|
this.#scopeLabel = options.scopeLabel;
|
|
1472
1624
|
this.title = this.#headerLabel();
|
|
1473
1625
|
// One spacer of breathing room; OverlayPanel supplies the two outer
|
|
@@ -1500,7 +1652,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1500
1652
|
onExit();
|
|
1501
1653
|
};
|
|
1502
1654
|
this.#sessionList.onRequestRender = () => this.#onRequestRender?.();
|
|
1503
|
-
if (this.#onDelete) {
|
|
1655
|
+
if (this.#onDelete || this.#resumeSource?.deleteConversation) {
|
|
1504
1656
|
this.#sessionList.onDeleteRequest = (session: T) => {
|
|
1505
1657
|
this.#showDeleteConfirmation(session);
|
|
1506
1658
|
};
|
|
@@ -1510,14 +1662,58 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1510
1662
|
void this.#toggleScope();
|
|
1511
1663
|
};
|
|
1512
1664
|
}
|
|
1665
|
+
this.#resumeHandlers = { onSelect, onCancel, onExit };
|
|
1666
|
+
this.#resumeTreeSource = (
|
|
1667
|
+
options as SessionSelectorOptions<T> & { resumeSource?: ResumeTreeSource }
|
|
1668
|
+
).resumeSource;
|
|
1669
|
+
this.#getTerminalRowsOption = options.getTerminalRows;
|
|
1670
|
+
// The resume picker's Tab cycles the three-way scope; it replaces the
|
|
1671
|
+
// folder/all toggle so the keys and the segmented control agree.
|
|
1672
|
+
if (this.#resumeSource) this.#sessionList.onToggleScope = () => this.#cycleResumeScope();
|
|
1513
1673
|
this.#contentSlot = new Container();
|
|
1514
1674
|
this.#contentSlot.addChild(this.#sessionList);
|
|
1515
1675
|
this.addChild(this.#contentSlot);
|
|
1676
|
+
this.#mountResumeTree();
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Swap the flat list for the ANSI three-level tree on the first frame that
|
|
1681
|
+
* reports no native `picker` support. Native terminals keep the flat list and
|
|
1682
|
+
* drive the resume model through `#describePicker`, so the tree never sees
|
|
1683
|
+
* their keys.
|
|
1684
|
+
*/
|
|
1685
|
+
#mountResumeTree(): void {
|
|
1686
|
+
if (this.#resumeTree || !this.#resumeTreeSource || !this.#resumeHandlers || !this.#contentSlot) return;
|
|
1687
|
+
const handlers = this.#resumeHandlers;
|
|
1688
|
+
this.#resumeTree = new ResumeTreeComponent({
|
|
1689
|
+
source: this.#resumeTreeSource,
|
|
1690
|
+
getTerminalRows: this.#getTerminalRowsOption,
|
|
1691
|
+
onSelect: conversation => {
|
|
1692
|
+
this.#sessionList.dispose();
|
|
1693
|
+
handlers.onSelect(conversation.session as unknown as T);
|
|
1694
|
+
},
|
|
1695
|
+
onCancel: () => {
|
|
1696
|
+
this.#sessionList.dispose();
|
|
1697
|
+
handlers.onCancel();
|
|
1698
|
+
},
|
|
1699
|
+
onExit: () => {
|
|
1700
|
+
this.#sessionList.dispose();
|
|
1701
|
+
handlers.onExit();
|
|
1702
|
+
},
|
|
1703
|
+
});
|
|
1704
|
+
this.#resumeTree.onRequestRender = () => this.#onRequestRender?.();
|
|
1705
|
+
this.#contentSlot.clear();
|
|
1706
|
+
this.#contentSlot.addChild(this.#resumeTree);
|
|
1707
|
+
this.title = this.#title;
|
|
1516
1708
|
}
|
|
1517
1709
|
|
|
1518
1710
|
#headerLabel(): string {
|
|
1519
1711
|
if (this.#scopeLabel === false) return this.#title;
|
|
1520
|
-
const scopeLabel =
|
|
1712
|
+
const scopeLabel =
|
|
1713
|
+
this.#scopeLabel ??
|
|
1714
|
+
(this.#scope === "all"
|
|
1715
|
+
? tuiText("resumeScopeAll", "all projects")
|
|
1716
|
+
: tuiText("resumeScopeWorkspace", "this workspace"));
|
|
1521
1717
|
return `${this.#title} (${scopeLabel})`;
|
|
1522
1718
|
}
|
|
1523
1719
|
|
|
@@ -1534,8 +1730,9 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1534
1730
|
if (!this.#loadAllSessions) return;
|
|
1535
1731
|
this.#toggling = true;
|
|
1536
1732
|
this.#messageContainer.clear();
|
|
1537
|
-
|
|
1538
|
-
this.#
|
|
1733
|
+
const loading = tuiText("resumeLoading", "Loading conversations…");
|
|
1734
|
+
this.#messageContainer.addChild(new Text(theme.fg("muted", loading), 0, 0));
|
|
1735
|
+
this.#message = { kind: "loading", text: loading };
|
|
1539
1736
|
this.#onRequestRender?.();
|
|
1540
1737
|
try {
|
|
1541
1738
|
global = await this.#loadAllSessions();
|
|
@@ -1562,6 +1759,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1562
1759
|
|
|
1563
1760
|
setOnRequestRender(callback: () => void): void {
|
|
1564
1761
|
this.#onRequestRender = callback;
|
|
1762
|
+
if (this.#resumeTree) this.#resumeTree.onRequestRender = callback;
|
|
1565
1763
|
}
|
|
1566
1764
|
/** Ignore input after selection while the host resumes the session. */
|
|
1567
1765
|
lockInput(): void {
|
|
@@ -1579,10 +1777,25 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1579
1777
|
*/
|
|
1580
1778
|
override dispose(): void {
|
|
1581
1779
|
this.#sessionList.dispose();
|
|
1780
|
+
this.#resumeTree?.dispose();
|
|
1582
1781
|
clearTimeout(this.#previewTimer);
|
|
1583
1782
|
super.dispose();
|
|
1584
1783
|
}
|
|
1585
1784
|
|
|
1785
|
+
/** True when some deleter is wired (the host callback or the resume source). */
|
|
1786
|
+
#canDelete(): boolean {
|
|
1787
|
+
return this.#onDelete !== undefined || this.#resumeSource?.deleteConversation !== undefined;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** Delete through the host callback, else the resume source's conversation entry. */
|
|
1791
|
+
#deleteSession(session: T): Promise<boolean> {
|
|
1792
|
+
if (this.#onDelete) return this.#onDelete(session);
|
|
1793
|
+
const source = this.#resumeSource;
|
|
1794
|
+
if (!source?.deleteConversation || !source.directory) return Promise.resolve(false);
|
|
1795
|
+
const context = resumeConversationContext(source.directory, session.path);
|
|
1796
|
+
return Promise.resolve(context ? source.deleteConversation(context.conversation) : false);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1586
1799
|
#clearError(): void {
|
|
1587
1800
|
this.#messageContainer.clear();
|
|
1588
1801
|
this.#message = undefined;
|
|
@@ -1609,11 +1822,12 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1609
1822
|
this.#onRequestRender?.();
|
|
1610
1823
|
};
|
|
1611
1824
|
const answer = async (option: string) => {
|
|
1612
|
-
if (option === "Yes" && this.#
|
|
1825
|
+
if (option === "Yes" && this.#canDelete()) {
|
|
1613
1826
|
this.#clearError();
|
|
1614
1827
|
try {
|
|
1615
|
-
const deleted = await this.#
|
|
1828
|
+
const deleted = await this.#deleteSession(session);
|
|
1616
1829
|
if (deleted) {
|
|
1830
|
+
this.#resumeHidden.add(session.path);
|
|
1617
1831
|
this.#sessionList.removeSession(session.path);
|
|
1618
1832
|
this.#folderSessions = this.#folderSessions.filter(s => s.path !== session.path);
|
|
1619
1833
|
if (this.#globalSessions) {
|
|
@@ -1677,13 +1891,35 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1677
1891
|
|
|
1678
1892
|
/** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
|
|
1679
1893
|
#footerLines(width: number): string[] {
|
|
1680
|
-
const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
|
|
1681
|
-
// Keys mirror SessionList#handleInput; cancel is `app.interrupt` (raw Escape when unbound).
|
|
1682
1894
|
const cancel = interruptKey();
|
|
1683
|
-
const hint =
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1895
|
+
const hint = this.#resumeTree
|
|
1896
|
+
? composeFooter(
|
|
1897
|
+
width,
|
|
1898
|
+
[
|
|
1899
|
+
...(this.#resumeTree.scope === "workspace"
|
|
1900
|
+
? []
|
|
1901
|
+
: [`^f ${tuiText("resumeFooterPaths", "full paths")}`]),
|
|
1902
|
+
`${formatKeyHint("enter")} resume`,
|
|
1903
|
+
`^r ${tuiText("resumeMenuRename", "rename")}`,
|
|
1904
|
+
`${formatKeyHint("delete")} delete`,
|
|
1905
|
+
`${formatKeyHint("tab")} ${tuiText("resumeFooterScope", "scope")}`,
|
|
1906
|
+
`/ ${tuiText("resumeFooterSearch", "search")}`,
|
|
1907
|
+
`r ${tuiText("resumeFooterRefresh", "refresh")}`,
|
|
1908
|
+
`p/P ${tuiText("resumeFooterPin", "pin")}`,
|
|
1909
|
+
`m ${tuiText("resumeFooterMenu", "menu")}`,
|
|
1910
|
+
`${cancel} ${tuiText("resumeFooterClose", "clear/close")}`,
|
|
1911
|
+
],
|
|
1912
|
+
[
|
|
1913
|
+
tuiText("resumeFooterClick", "click select"),
|
|
1914
|
+
tuiText("resumeFooterDoubleClick", "dbl-click resume"),
|
|
1915
|
+
tuiText("resumeFooterWheel", "wheel"),
|
|
1916
|
+
`⋯ ${tuiText("resumeFooterMenu", "menu")}`,
|
|
1917
|
+
],
|
|
1918
|
+
)
|
|
1919
|
+
: theme.fg(
|
|
1920
|
+
"muted",
|
|
1921
|
+
`[${this.#onDelete ? `${formatKeyHints(["delete", "backspace"])} delete · ` : ""}${formatKeyHint("enter")} select · ${formatKeyHint("tab")} ${this.#scope === "all" ? tuiText("resumeScopeWorkspace", "this workspace") : tuiText("resumeScopeAll", "all projects")} · ${cancel} cancel]`,
|
|
1922
|
+
);
|
|
1687
1923
|
return [row("", width), row(hint, width), row("", width), bottomBorder(width)];
|
|
1688
1924
|
}
|
|
1689
1925
|
|
|
@@ -1704,7 +1940,14 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1704
1940
|
* path — and the key-hint footer.
|
|
1705
1941
|
*/
|
|
1706
1942
|
override describe(cx: DescribeContext): NativeNode {
|
|
1707
|
-
if (cx.supports("picker"))
|
|
1943
|
+
if (cx.supports("picker")) {
|
|
1944
|
+
// The native picker owns its own event model; raw keys must keep going
|
|
1945
|
+
// to the list even though the ANSI tree is constructed.
|
|
1946
|
+
this.#nativePicker = true;
|
|
1947
|
+
return this.#describePicker();
|
|
1948
|
+
}
|
|
1949
|
+
this.#nativePicker = false;
|
|
1950
|
+
this.#mountResumeTree();
|
|
1708
1951
|
const list = this.#sessionList.describeList();
|
|
1709
1952
|
const memo = this.#nativeMemo;
|
|
1710
1953
|
if (
|
|
@@ -1752,6 +1995,283 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1752
1995
|
return result;
|
|
1753
1996
|
}
|
|
1754
1997
|
|
|
1998
|
+
/**
|
|
1999
|
+
* Resume-model sheet: the model's project/workspace sections (or its
|
|
2000
|
+
* workspace sections in project scope), three-way scope tabs with counts,
|
|
2001
|
+
* conversation cards with turns/branch/model facts, the resume-tree
|
|
2002
|
+
* preview and the same action bar the ANSI footer carries (resume, delete,
|
|
2003
|
+
* pin, refresh, scope, close).
|
|
2004
|
+
*/
|
|
2005
|
+
#describeResumePicker(): NativeNode {
|
|
2006
|
+
const source = this.#resumeSource!;
|
|
2007
|
+
const list = this.#sessionList;
|
|
2008
|
+
const model = this.#resumeModel();
|
|
2009
|
+
if (!model) return this.#describeResumeLoading(source);
|
|
2010
|
+
list.syncSessions(this.#resumeSessions(model), list.selectedSession?.path ?? model.selected);
|
|
2011
|
+
const selected = list.selectedSession?.path ?? null;
|
|
2012
|
+
const preview = this.#previewFor(list.selectedSession, session => {
|
|
2013
|
+
const context = source.directory ? resumeConversationContext(source.directory, session.path) : undefined;
|
|
2014
|
+
return context ? resumeConversationPreview(context.conversation, context.project, context.workspace) : [];
|
|
2015
|
+
});
|
|
2016
|
+
const message = this.#message;
|
|
2017
|
+
const errorOpen = this.#pickerErrorOpen && message?.kind === "error";
|
|
2018
|
+
const choice = this.#deleteChoice;
|
|
2019
|
+
const memo = this.#resumePickerMemo;
|
|
2020
|
+
if (
|
|
2021
|
+
memo?.model === model &&
|
|
2022
|
+
memo.preview === preview &&
|
|
2023
|
+
memo.choice === choice &&
|
|
2024
|
+
memo.errorOpen === errorOpen &&
|
|
2025
|
+
memo.message === message &&
|
|
2026
|
+
memo.selected === selected
|
|
2027
|
+
) {
|
|
2028
|
+
return memo.node;
|
|
2029
|
+
}
|
|
2030
|
+
const loading = message?.kind === "loading";
|
|
2031
|
+
const kind = model.view.scope.kind;
|
|
2032
|
+
const selectedConversation =
|
|
2033
|
+
selected && source.directory ? resumeConversationContext(source.directory, selected) : undefined;
|
|
2034
|
+
const actions = [pickerAction("resume", this.#actionLabel, "enter", { primary: true })];
|
|
2035
|
+
if (this.#canDelete()) {
|
|
2036
|
+
actions.push(
|
|
2037
|
+
pickerAction(
|
|
2038
|
+
"delete",
|
|
2039
|
+
this.#deleteMode === "trash"
|
|
2040
|
+
? tuiText("resumeDeleteTrash", "Move to trash")
|
|
2041
|
+
: tuiText("resumeDeletePermanent", "Delete"),
|
|
2042
|
+
"backspace",
|
|
2043
|
+
),
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
if (source.pinConversation && selectedConversation) {
|
|
2047
|
+
actions.push(
|
|
2048
|
+
pickerAction(
|
|
2049
|
+
"pin",
|
|
2050
|
+
selectedConversation.conversation.pinned
|
|
2051
|
+
? tuiText("resumeUnpinAction", "Unpin")
|
|
2052
|
+
: tuiText("resumePinAction", "Pin"),
|
|
2053
|
+
"p",
|
|
2054
|
+
),
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
if (source.refresh) actions.push(pickerAction("refresh", tuiText("resumeRefreshAction", "Refresh"), "r"));
|
|
2058
|
+
if (list.onToggleScope) actions.push(pickerAction("scope", resumeScopeLabel(nextResumeScope(kind)), "tab"));
|
|
2059
|
+
actions.push(
|
|
2060
|
+
pickerAction("close", "Close", boundKeys("app.interrupt", ["escape"])[0] ?? "escape", { end: true }),
|
|
2061
|
+
);
|
|
2062
|
+
const banner =
|
|
2063
|
+
!loading && !errorOpen && model.hidden > 0
|
|
2064
|
+
? tuiTextFmt("resumeHiddenByFilterFmt", "%d conversations hidden by filter", model.hidden)
|
|
2065
|
+
: undefined;
|
|
2066
|
+
const title = this.#pickerTitle;
|
|
2067
|
+
const folder = this.#scopeLabel === false ? undefined : (this.#scopeLabel ?? path.basename(getProjectDir()));
|
|
2068
|
+
const result = picker(
|
|
2069
|
+
{
|
|
2070
|
+
...(title ? { title, ...(folder ? { subtitle: folder } : {}) } : {}),
|
|
2071
|
+
icon: "history",
|
|
2072
|
+
noun: "conversations",
|
|
2073
|
+
size: this.#standalone ? "screen" : "lg",
|
|
2074
|
+
layout: "cards",
|
|
2075
|
+
preview: "side",
|
|
2076
|
+
query: list.searchInput.getValue(),
|
|
2077
|
+
cursor: list.searchInput.getCursor(),
|
|
2078
|
+
placeholder: model.placeholder,
|
|
2079
|
+
tabs: model.tabs,
|
|
2080
|
+
tab: kind,
|
|
2081
|
+
columns: [
|
|
2082
|
+
{ id: "when", format: "time" },
|
|
2083
|
+
{ id: "turns", format: "num" },
|
|
2084
|
+
{ id: "branch", format: "dim", priority: 1 },
|
|
2085
|
+
{ id: "model", format: "dim", priority: 2 },
|
|
2086
|
+
],
|
|
2087
|
+
items: model.items,
|
|
2088
|
+
order: model.order,
|
|
2089
|
+
...(model.hits ? { hits: model.hits } : {}),
|
|
2090
|
+
selected,
|
|
2091
|
+
current: model.current,
|
|
2092
|
+
total: model.total,
|
|
2093
|
+
actions,
|
|
2094
|
+
state: loading ? "loading" : errorOpen ? "error" : "ready",
|
|
2095
|
+
...((loading || errorOpen) && message ? { message: message.text } : banner ? { message: banner } : {}),
|
|
2096
|
+
empty: model.empty,
|
|
2097
|
+
confirm: choice
|
|
2098
|
+
? {
|
|
2099
|
+
text:
|
|
2100
|
+
this.#deleteMode === "trash"
|
|
2101
|
+
? tuiTextFmt("resumeDeleteTrashFmt", "Move “%s” to trash?", sessionLabel(choice.session))
|
|
2102
|
+
: tuiTextFmt(
|
|
2103
|
+
"resumeDeletePermanentFmt",
|
|
2104
|
+
"Permanently delete “%s”?",
|
|
2105
|
+
sessionLabel(choice.session),
|
|
2106
|
+
),
|
|
2107
|
+
act: "delete-confirm",
|
|
2108
|
+
label:
|
|
2109
|
+
this.#deleteMode === "trash"
|
|
2110
|
+
? tuiText("resumeDeleteTrash", "Move to trash")
|
|
2111
|
+
: tuiText("resumeDeletePermanent", "Delete"),
|
|
2112
|
+
}
|
|
2113
|
+
: null,
|
|
2114
|
+
},
|
|
2115
|
+
preview,
|
|
2116
|
+
);
|
|
2117
|
+
this.#resumePickerMemo = { model, preview, choice, errorOpen, message, selected, node: result };
|
|
2118
|
+
return result;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** Skeleton sheet while the host's directory scan is still running (or failed). */
|
|
2122
|
+
#describeResumeLoading(source: ResumeSourceLike): NativeNode {
|
|
2123
|
+
const list = this.#sessionList;
|
|
2124
|
+
const error = this.#message?.kind === "error" ? this.#message.text : source.loadError?.();
|
|
2125
|
+
const query = list.searchInput.getValue();
|
|
2126
|
+
const cursor = list.searchInput.getCursor();
|
|
2127
|
+
const memo = this.#resumeLoadingMemo;
|
|
2128
|
+
if (memo?.source === source && memo.error === error && memo.query === query && memo.cursor === cursor) {
|
|
2129
|
+
return memo.node;
|
|
2130
|
+
}
|
|
2131
|
+
const title = this.#pickerTitle;
|
|
2132
|
+
const node = picker({
|
|
2133
|
+
...(title ? { title } : {}),
|
|
2134
|
+
icon: "history",
|
|
2135
|
+
noun: "conversations",
|
|
2136
|
+
size: this.#standalone ? "screen" : "lg",
|
|
2137
|
+
layout: "cards",
|
|
2138
|
+
preview: "none",
|
|
2139
|
+
query,
|
|
2140
|
+
cursor,
|
|
2141
|
+
items: [],
|
|
2142
|
+
order: [],
|
|
2143
|
+
selected: null,
|
|
2144
|
+
actions: [
|
|
2145
|
+
pickerAction("close", "Close", boundKeys("app.interrupt", ["escape"])[0] ?? "escape", { end: true }),
|
|
2146
|
+
],
|
|
2147
|
+
state: error ? "error" : "loading",
|
|
2148
|
+
message: error ?? tuiText("resumeLoading", "Loading conversations…"),
|
|
2149
|
+
});
|
|
2150
|
+
this.#resumeLoadingMemo = { source, error, query, cursor, node };
|
|
2151
|
+
return node;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/** The memoized resume model for the active scope, query and directory snapshot. */
|
|
2155
|
+
#resumeModel(): ResumePickerModel | undefined {
|
|
2156
|
+
const source = this.#resumeSource!;
|
|
2157
|
+
const query = this.#sessionList.searchInput.getValue();
|
|
2158
|
+
const currentPath = this.#getCurrentSessionPath();
|
|
2159
|
+
const scope = this.#resumeScope;
|
|
2160
|
+
const scopeKey =
|
|
2161
|
+
typeof scope === "string" ? scope : `${scope.kind}:${scope.projectKey ?? ""}:${scope.workspaceKey ?? ""}`;
|
|
2162
|
+
const kind = typeof scope === "string" ? scope : scope.kind;
|
|
2163
|
+
const showCwd = kind !== "workspace";
|
|
2164
|
+
const cached = this.#resumeModelMemo;
|
|
2165
|
+
if (
|
|
2166
|
+
cached?.source === source &&
|
|
2167
|
+
cached.directory === source.directory &&
|
|
2168
|
+
cached.query === query &&
|
|
2169
|
+
cached.scopeKey === scopeKey &&
|
|
2170
|
+
cached.currentPath === currentPath &&
|
|
2171
|
+
cached.hidden === this.#resumeHidden.size &&
|
|
2172
|
+
cached.showCwd === showCwd
|
|
2173
|
+
) {
|
|
2174
|
+
return cached.model;
|
|
2175
|
+
}
|
|
2176
|
+
const model = buildResumePickerModel(source, {
|
|
2177
|
+
scope,
|
|
2178
|
+
query,
|
|
2179
|
+
currentSessionPath: currentPath,
|
|
2180
|
+
hidden: this.#resumeHidden,
|
|
2181
|
+
showCwd,
|
|
2182
|
+
preferredPath: this.#sessionList.selectedSession?.path,
|
|
2183
|
+
});
|
|
2184
|
+
this.#resumeModelMemo = {
|
|
2185
|
+
source,
|
|
2186
|
+
directory: source.directory,
|
|
2187
|
+
query,
|
|
2188
|
+
scopeKey,
|
|
2189
|
+
currentPath,
|
|
2190
|
+
hidden: this.#resumeHidden.size,
|
|
2191
|
+
showCwd,
|
|
2192
|
+
model,
|
|
2193
|
+
};
|
|
2194
|
+
return model;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/** The view's sessions as list entries; rebuilt only when the view itself changes. */
|
|
2198
|
+
#resumeSessions(model: ResumePickerModel): readonly T[] {
|
|
2199
|
+
const memo = this.#resumeSessionsMemo;
|
|
2200
|
+
if (memo?.model === model) return memo.sessions;
|
|
2201
|
+
const sessions = (model.view.conversations ?? []).map(row => row.conversation.session as unknown as T);
|
|
2202
|
+
this.#resumeSessionsMemo = { model, sessions };
|
|
2203
|
+
return sessions;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
#invalidateResume(): void {
|
|
2207
|
+
this.#resumeModelMemo = undefined;
|
|
2208
|
+
this.#resumeSessionsMemo = undefined;
|
|
2209
|
+
this.#resumePickerMemo = undefined;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/** Pin/unpin the selected conversation, then re-read the directory. */
|
|
2213
|
+
#toggleResumePin(): void {
|
|
2214
|
+
const source = this.#resumeSource;
|
|
2215
|
+
const pin = source?.pinConversation;
|
|
2216
|
+
const path = this.#sessionList.selectedSession?.path;
|
|
2217
|
+
if (!source || !pin || !path || !source.directory) return;
|
|
2218
|
+
const context = resumeConversationContext(source.directory, path);
|
|
2219
|
+
if (!context) return;
|
|
2220
|
+
void (async () => {
|
|
2221
|
+
await pin.call(source, context.conversation, !context.conversation.pinned);
|
|
2222
|
+
await source.refresh?.();
|
|
2223
|
+
this.#invalidateResume();
|
|
2224
|
+
this.#onRequestRender?.();
|
|
2225
|
+
})().catch((error: unknown) => {
|
|
2226
|
+
this.#showError(error instanceof Error ? error.message : String(error));
|
|
2227
|
+
this.#onRequestRender?.();
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/** Re-scan the directory, showing the loading line meanwhile. */
|
|
2232
|
+
#refreshResume(): void {
|
|
2233
|
+
const source = this.#resumeSource;
|
|
2234
|
+
const refresh = source?.refresh;
|
|
2235
|
+
if (!source || !refresh) return;
|
|
2236
|
+
this.#message = { kind: "loading", text: tuiText("resumeLoading", "Loading conversations…") };
|
|
2237
|
+
this.#invalidateResume();
|
|
2238
|
+
this.#onRequestRender?.();
|
|
2239
|
+
void (async () => {
|
|
2240
|
+
await refresh.call(source);
|
|
2241
|
+
if (this.#message?.kind === "loading") this.#message = undefined;
|
|
2242
|
+
this.#invalidateResume();
|
|
2243
|
+
this.#onRequestRender?.();
|
|
2244
|
+
})().catch((error: unknown) => {
|
|
2245
|
+
this.#showError(error instanceof Error ? error.message : String(error));
|
|
2246
|
+
this.#onRequestRender?.();
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** Tab / the scope button: workspace → project → all → workspace. */
|
|
2251
|
+
#cycleResumeScope(): void {
|
|
2252
|
+
const current = this.#resumeModel()?.view.scope;
|
|
2253
|
+
this.#setResumeScope(
|
|
2254
|
+
nextResumeScope(
|
|
2255
|
+
current?.kind ?? (typeof this.#resumeScope === "string" ? this.#resumeScope : this.#resumeScope.kind),
|
|
2256
|
+
),
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/** Switch the three-way scope, pinning the group the current scope resolved to. */
|
|
2261
|
+
#setResumeScope(kind: ResumeScopeKindLike): void {
|
|
2262
|
+
const current = this.#resumeModel()?.view.scope;
|
|
2263
|
+
if (kind === "all") this.#resumeScope = "all";
|
|
2264
|
+
else if (kind === "project") {
|
|
2265
|
+
this.#resumeScope = current?.projectKey ? { kind, projectKey: current.projectKey } : kind;
|
|
2266
|
+
} else {
|
|
2267
|
+
this.#resumeScope =
|
|
2268
|
+
current?.kind === "workspace" && current.workspaceKey ? { kind, workspaceKey: current.workspaceKey } : kind;
|
|
2269
|
+
}
|
|
2270
|
+
void this.#resumeSource?.persistScope?.(kind);
|
|
2271
|
+
this.#invalidateResume();
|
|
2272
|
+
this.#onRequestRender?.();
|
|
2273
|
+
}
|
|
2274
|
+
|
|
1755
2275
|
/**
|
|
1756
2276
|
* `lg` cards sheet (`screen` for the standalone app): sessions grouped by
|
|
1757
2277
|
* day (ranked flat while searching), the selected session's preview, the
|
|
@@ -1760,6 +2280,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1760
2280
|
* Untitled unless the caller named it; the placeholder says the scope.
|
|
1761
2281
|
*/
|
|
1762
2282
|
#describePicker(): NativeNode {
|
|
2283
|
+
if (this.#resumeSource) return this.#describeResumePicker();
|
|
1763
2284
|
const list = this.#sessionList;
|
|
1764
2285
|
const view = list.pickerView();
|
|
1765
2286
|
const preview = this.#previewFor(list.selectedSession);
|
|
@@ -1840,7 +2361,14 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1840
2361
|
* selection, then only after the selection has rested
|
|
1841
2362
|
* {@link PREVIEW_SETTLE_MS} (the old preview stays meanwhile).
|
|
1842
2363
|
*/
|
|
1843
|
-
#previewFor(
|
|
2364
|
+
#previewFor(
|
|
2365
|
+
target: T | undefined,
|
|
2366
|
+
build: (session: T) => readonly NativeChild[] = session =>
|
|
2367
|
+
sessionPreview(
|
|
2368
|
+
session,
|
|
2369
|
+
session.parentSessionPath ? this.#sessionList.parentLabel(session.parentSessionPath) : undefined,
|
|
2370
|
+
),
|
|
2371
|
+
): readonly NativeChild[] {
|
|
1844
2372
|
const shown = this.#preview;
|
|
1845
2373
|
if (shown && shown.session === target) return shown.nodes;
|
|
1846
2374
|
if (shown && target !== this.#previewSettled) {
|
|
@@ -1855,12 +2383,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1855
2383
|
}
|
|
1856
2384
|
return shown.nodes;
|
|
1857
2385
|
}
|
|
1858
|
-
const nodes = target
|
|
1859
|
-
? sessionPreview(
|
|
1860
|
-
target,
|
|
1861
|
-
target.parentSessionPath ? this.#sessionList.parentLabel(target.parentSessionPath) : undefined,
|
|
1862
|
-
)
|
|
1863
|
-
: [];
|
|
2386
|
+
const nodes = target ? build(target) : [];
|
|
1864
2387
|
this.#preview = { session: target, nodes };
|
|
1865
2388
|
return nodes;
|
|
1866
2389
|
}
|
|
@@ -1882,12 +2405,16 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1882
2405
|
}
|
|
1883
2406
|
if (ev.act === "delete-confirm") choice?.confirm();
|
|
1884
2407
|
else if (ev.act === "cancel") choice?.cancel();
|
|
1885
|
-
else if (ev.act === "
|
|
2408
|
+
else if (ev.act === "tab") {
|
|
2409
|
+
if (this.#resumeSource && ev.value) this.#setResumeScope(ev.value as ResumeScopeKindLike);
|
|
2410
|
+
} else if (ev.act === "close") {
|
|
1886
2411
|
if (choice) choice.cancel();
|
|
1887
2412
|
else list.onCancel?.();
|
|
1888
2413
|
} else if (choice) return;
|
|
1889
2414
|
else if (ev.act === "resume") list.resumeSelected();
|
|
1890
2415
|
else if (ev.act === "delete") list.requestDelete();
|
|
2416
|
+
else if (ev.act === "pin") this.#toggleResumePin();
|
|
2417
|
+
else if (ev.act === "refresh") this.#refreshResume();
|
|
1891
2418
|
else if (ev.act === "scope") {
|
|
1892
2419
|
list.onToggleScope?.();
|
|
1893
2420
|
} else if (ev.act === "clear") list.clearSearch();
|
|
@@ -1919,6 +2446,8 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1919
2446
|
}
|
|
1920
2447
|
if (this.#confirmationDialog) {
|
|
1921
2448
|
this.#confirmationDialog.handleInput(keyData);
|
|
2449
|
+
} else if (this.#resumeTree && !this.#nativePicker) {
|
|
2450
|
+
this.#resumeTree.handleInput(keyData);
|
|
1922
2451
|
} else {
|
|
1923
2452
|
this.#sessionList.handleInput(keyData);
|
|
1924
2453
|
}
|
|
@@ -1932,6 +2461,12 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1932
2461
|
*/
|
|
1933
2462
|
#handleMouse(data: string): void {
|
|
1934
2463
|
if (this.#confirmationDialog) return;
|
|
2464
|
+
if (this.#resumeTree) {
|
|
2465
|
+
// The panel paints the tree inside `│ ` … ` │`; rows start at
|
|
2466
|
+
// #listLineOffset and the first content column is 2.
|
|
2467
|
+
this.#resumeTree.handleMouse(data, { row: this.#listLineOffset, col: 2 });
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
1935
2470
|
routeSgrMouseInput(data, event => {
|
|
1936
2471
|
if (event.wheel !== null) {
|
|
1937
2472
|
this.#sessionList.handleWheel(event.wheel);
|
|
@@ -1947,4 +2482,9 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1947
2482
|
getSessionList(): SessionList<T> {
|
|
1948
2483
|
return this.#sessionList;
|
|
1949
2484
|
}
|
|
2485
|
+
|
|
2486
|
+
/** The ANSI resume tree when the host supplied a resume source. */
|
|
2487
|
+
getResumeTree(): ResumeTreeComponent | undefined {
|
|
2488
|
+
return this.#resumeTree;
|
|
2489
|
+
}
|
|
1950
2490
|
}
|