pi-better-background-tasks 0.4.0 → 0.6.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/README.md +57 -4
- package/package.json +1 -1
- package/src/failures.ts +9 -1
- package/src/navigator-provider.ts +9 -6
- package/src/output.ts +101 -8
- package/src/runtime.ts +189 -21
- package/src/sandbox.ts +3 -0
- package/src/shared-failure-observations.ts +70 -21
- package/src/shared-log-utils.ts +6 -12
- package/src/shared-navigator.ts +224 -125
- package/src/shared-sandbox-core.ts +194 -22
- package/src/tools.ts +53 -11
- package/src/types.ts +20 -0
package/src/shared-navigator.ts
CHANGED
|
@@ -93,8 +93,19 @@ type NavigatorState = {
|
|
|
93
93
|
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
94
94
|
mainListDeadlineScheduler?: RenderScheduler;
|
|
95
95
|
editorComponent?: Component;
|
|
96
|
+
/** The editor Pi currently has mounted (the wrapper the factory returned last). */
|
|
97
|
+
editorFocusTarget?: unknown;
|
|
98
|
+
/** How many times that wrapper has rendered, so an overlay can tell whether it is still on screen. */
|
|
99
|
+
editorRenders?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Whether that wrapper rendered between the overlay's last two frames: true while it is on screen,
|
|
102
|
+
* including inside another extension's editor that wraps it. Undefined until two frames compare.
|
|
103
|
+
*/
|
|
104
|
+
editorRenderedLastFrame?: boolean;
|
|
96
105
|
detailOverlayRows?: number;
|
|
97
106
|
dispose?: () => void;
|
|
107
|
+
/** The mounted detail overlay, reused instead of stacking a second one. */
|
|
108
|
+
overlay?: { showDetail(navigatorId: string): void; focus(): void };
|
|
98
109
|
};
|
|
99
110
|
|
|
100
111
|
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
@@ -199,6 +210,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
199
210
|
const s = state();
|
|
200
211
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
201
212
|
s.dispose = undefined;
|
|
213
|
+
s.overlay = undefined;
|
|
202
214
|
stopMainListWidget();
|
|
203
215
|
const activeCtx = ctx ?? s.uiCtx;
|
|
204
216
|
s.uiCtx = undefined;
|
|
@@ -627,7 +639,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
627
639
|
|
|
628
640
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
629
641
|
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
630
|
-
|
|
642
|
+
const proxy: any = new Proxy(inner, {
|
|
631
643
|
get(target, prop) {
|
|
632
644
|
if (prop === "handleInput") {
|
|
633
645
|
return (data: string) => {
|
|
@@ -637,6 +649,13 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
|
637
649
|
target.handleInput(data);
|
|
638
650
|
};
|
|
639
651
|
}
|
|
652
|
+
if (prop === "render" && typeof target.render === "function") {
|
|
653
|
+
return (...args: unknown[]) => {
|
|
654
|
+
const s = state();
|
|
655
|
+
if (s.editorFocusTarget === proxy) s.editorRenders = (s.editorRenders ?? 0) + 1;
|
|
656
|
+
return target.render(...args);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
640
659
|
const value = Reflect.get(target, prop);
|
|
641
660
|
return typeof value === "function" ? value.bind(target) : value;
|
|
642
661
|
},
|
|
@@ -644,12 +663,24 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
|
644
663
|
return Reflect.set(target, prop, value);
|
|
645
664
|
},
|
|
646
665
|
});
|
|
666
|
+
// Pi mounts and focuses whatever the factory returns; an extension that
|
|
667
|
+
// re-installs the editor replaces it, so this always names the live one.
|
|
668
|
+
state().editorFocusTarget = proxy;
|
|
669
|
+
return proxy;
|
|
647
670
|
}
|
|
648
671
|
|
|
649
672
|
function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
650
673
|
const s = state();
|
|
651
674
|
const rows = listRows();
|
|
652
|
-
if (rows.length === 0)
|
|
675
|
+
if (rows.length === 0) {
|
|
676
|
+
// Every row is gone, but an overlay that lost focus may still be mounted: Esc still closes it.
|
|
677
|
+
if (s.overlay && deps.matchKey(data, "escape")) {
|
|
678
|
+
unfocusMainList();
|
|
679
|
+
dismissOverlay();
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
653
684
|
if (!s.mainListFocused) {
|
|
654
685
|
if (!deps.isOpenTrigger(data)) return false;
|
|
655
686
|
focusMainList();
|
|
@@ -673,7 +704,10 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
673
704
|
}
|
|
674
705
|
if (deps.matchKey(data, "enter")) {
|
|
675
706
|
const selected = selectedMainListRow();
|
|
676
|
-
if (selected?.parentRow)
|
|
707
|
+
if (selected?.parentRow) {
|
|
708
|
+
unfocusMainList();
|
|
709
|
+
dismissOverlay();
|
|
710
|
+
}
|
|
677
711
|
else openNavigator();
|
|
678
712
|
return true;
|
|
679
713
|
}
|
|
@@ -683,11 +717,32 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
683
717
|
}
|
|
684
718
|
if (deps.matchKey(data, "escape") || deps.isOpenTrigger(data)) {
|
|
685
719
|
unfocusMainList();
|
|
720
|
+
dismissOverlay();
|
|
686
721
|
return true;
|
|
687
722
|
}
|
|
688
723
|
return false;
|
|
689
724
|
}
|
|
690
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Whether pi-tui has the editor our wrapper returned mounted (#332). Pi mounts whatever the factory
|
|
728
|
+
* returns, so another extension's `setEditorComponent` (a foreign factory, or `undefined` for Pi's
|
|
729
|
+
* default editor) unmounts it, and no key reaches our input handler after that. `undefined` means
|
|
730
|
+
* the host cannot say (no `isComponentMounted`), and callers keep their earlier behaviour.
|
|
731
|
+
*/
|
|
732
|
+
function liveEditorMounted(tui: unknown): boolean | undefined {
|
|
733
|
+
const live = state().editorFocusTarget;
|
|
734
|
+
const check = (tui as { isComponentMounted?: (component: unknown) => boolean } | undefined)?.isComponentMounted;
|
|
735
|
+
if (!live || typeof check !== "function") return undefined;
|
|
736
|
+
try { return check.call(tui, live) === true; } catch { return undefined; }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Close a detail overlay that is still mounted while the editor has focus. */
|
|
740
|
+
function dismissOverlay(): void {
|
|
741
|
+
const s = state();
|
|
742
|
+
if (!s.overlay) return;
|
|
743
|
+
try { s.dispose?.(); } catch { /* ignore */ }
|
|
744
|
+
}
|
|
745
|
+
|
|
691
746
|
function handleMainListCloseKey(): void {
|
|
692
747
|
const row = selectedMainListRow();
|
|
693
748
|
if (!row || row.parentRow) return;
|
|
@@ -721,20 +776,59 @@ function openNavigator(): void {
|
|
|
721
776
|
const rows = listRows();
|
|
722
777
|
if (rows.length === 0) return;
|
|
723
778
|
const selectedId = selectedMainListRow()?.navigatorId ?? rows[0]!.navigatorId;
|
|
779
|
+
// An overlay that lost focus (another UI took it and handed it to the editor)
|
|
780
|
+
// is still mounted: switch its detail and focus it rather than stacking a
|
|
781
|
+
// second overlay over it, whose Esc would only reveal the stale one below.
|
|
782
|
+
const mounted = s.overlay;
|
|
783
|
+
if (mounted) {
|
|
784
|
+
// showDetail may close the overlay (and clear s.overlay); focus() is then a no-op.
|
|
785
|
+
mounted.showDetail(selectedId);
|
|
786
|
+
mounted.focus();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
724
789
|
try {
|
|
725
790
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
726
791
|
s.dispose = undefined;
|
|
792
|
+
s.editorRenderedLastFrame = undefined;
|
|
727
793
|
let disposeToken: (() => void) | undefined;
|
|
794
|
+
let overlayRef: NavigatorState["overlay"];
|
|
795
|
+
let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
|
|
796
|
+
const release = () => {
|
|
797
|
+
if (overlayRef && s.overlay === overlayRef) s.overlay = undefined;
|
|
798
|
+
};
|
|
728
799
|
const opened = (ctx.ui as any).custom((tui: any, theme: any, _keybindings: any, done: (v: null) => void) => {
|
|
729
|
-
const component = createOverlayComponent(rows, deps, tui, theme,
|
|
800
|
+
const component = createOverlayComponent(rows, deps, tui, theme, (value) => {
|
|
801
|
+
release();
|
|
802
|
+
// Pi restores focus to the editor that was focused when the overlay opened. If an
|
|
803
|
+
// extension re-installed the editor since, that instance is unmounted and every key
|
|
804
|
+
// would be lost; hand focus to the live editor before closing.
|
|
805
|
+
// Only an editor on screen can take focus: after another extension replaced ours, Pi already
|
|
806
|
+
// focused its replacement, and handing focus to our unmounted wrapper would swallow every key.
|
|
807
|
+
// An extension that wraps ours mounts its own component, so pi-tui does not find ours, but ours
|
|
808
|
+
// still renders and handles every key through it: that is still the live editor (#332).
|
|
809
|
+
const liveEditor = s.editorFocusTarget;
|
|
810
|
+
if (liveEditor && (liveEditorMounted(tui) !== false || s.editorRenderedLastFrame !== false)) {
|
|
811
|
+
try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
|
|
812
|
+
}
|
|
813
|
+
done(value);
|
|
814
|
+
}, () => {
|
|
730
815
|
s.lastHint = undefined;
|
|
731
816
|
refreshBackgroundWorkNavigator(ctx);
|
|
732
817
|
}, selectedId);
|
|
733
|
-
|
|
818
|
+
overlayRef = {
|
|
819
|
+
showDetail: (navigatorId) => component.showDetail(navigatorId),
|
|
820
|
+
focus: () => {
|
|
821
|
+
if (s.overlay !== overlayRef) return;
|
|
822
|
+
try { handle?.focus?.(); } catch { /* ignore */ }
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
s.overlay = overlayRef;
|
|
826
|
+
disposeToken = () => component.dismiss();
|
|
734
827
|
s.dispose = disposeToken;
|
|
735
828
|
return component;
|
|
736
|
-
}, { overlay: true, overlayOptions: detailOverlayOptions });
|
|
829
|
+
}, { overlay: true, overlayOptions: detailOverlayOptions, onHandle: (h: typeof handle) => { handle = h; } });
|
|
737
830
|
const clear = () => {
|
|
831
|
+
release();
|
|
738
832
|
if (s.dispose === disposeToken) s.dispose = undefined;
|
|
739
833
|
};
|
|
740
834
|
void Promise.resolve(opened).then(clear, clear);
|
|
@@ -750,16 +844,16 @@ function createOverlayComponent(
|
|
|
750
844
|
theme: { fg?(color: string, value: string): string } | undefined,
|
|
751
845
|
done: (v: null) => void,
|
|
752
846
|
onClosed: () => void,
|
|
753
|
-
initialDetailId
|
|
847
|
+
initialDetailId: string,
|
|
754
848
|
) {
|
|
849
|
+
// The overlay only ever shows one row's detail; the rail below it is the list.
|
|
755
850
|
const overlayState: OverlayState = { rows: initialRows, selected: 0 };
|
|
756
|
-
let mode: "list" | "detail" = initialDetailId ? "detail" : "list";
|
|
757
851
|
let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
|
|
758
852
|
const expandedSections = new Set<string>();
|
|
759
|
-
let detailId: string
|
|
760
|
-
let detail: BackgroundWorkDetail | null =
|
|
853
|
+
let detailId: string = initialDetailId;
|
|
854
|
+
let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
|
|
761
855
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
762
|
-
|
|
856
|
+
{
|
|
763
857
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
764
858
|
if (idx >= 0) overlayState.selected = idx;
|
|
765
859
|
}
|
|
@@ -776,7 +870,7 @@ function createOverlayComponent(
|
|
|
776
870
|
}
|
|
777
871
|
|
|
778
872
|
const detailScheduler = createRenderScheduler(() => {
|
|
779
|
-
if (
|
|
873
|
+
if (closed) return;
|
|
780
874
|
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
781
875
|
requestRender();
|
|
782
876
|
startDetailTimer();
|
|
@@ -837,29 +931,11 @@ function createOverlayComponent(
|
|
|
837
931
|
expandedSections.clear();
|
|
838
932
|
detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
|
|
839
933
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
840
|
-
mode = "detail";
|
|
841
934
|
startDetailTimer();
|
|
842
935
|
requestRender();
|
|
843
936
|
}
|
|
844
937
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
function leaveDetail(): void {
|
|
848
|
-
if (mode !== "detail") return;
|
|
849
|
-
const viewedId = detailId;
|
|
850
|
-
clearCloseArm();
|
|
851
|
-
stopDetailTimer();
|
|
852
|
-
mode = "list";
|
|
853
|
-
detail = null;
|
|
854
|
-
detailId = null;
|
|
855
|
-
expandedSections.clear();
|
|
856
|
-
refreshRows();
|
|
857
|
-
if (viewedId) {
|
|
858
|
-
const idx = overlayState.rows.findIndex((row) => row.navigatorId === viewedId);
|
|
859
|
-
if (idx >= 0) overlayState.selected = idx;
|
|
860
|
-
}
|
|
861
|
-
requestRender();
|
|
862
|
-
}
|
|
938
|
+
startDetailTimer();
|
|
863
939
|
|
|
864
940
|
function close(): void {
|
|
865
941
|
if (closed) return;
|
|
@@ -871,20 +947,28 @@ function createOverlayComponent(
|
|
|
871
947
|
|
|
872
948
|
function handleCloseKey(): void {
|
|
873
949
|
const row = selectedRow();
|
|
874
|
-
if (!row) return;
|
|
950
|
+
if (!row || row.parentRow) return;
|
|
875
951
|
const now = Date.now();
|
|
876
952
|
if (closeArm?.id === row.navigatorId && now >= closeArm.armedAt && now < closeArm.armedAt + CLOSE_ARM_MS) {
|
|
877
953
|
clearCloseArm();
|
|
954
|
+
const before = overlayState.rows;
|
|
955
|
+
const closedIdx = overlayState.selected;
|
|
878
956
|
closeFor(row);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
detail = null;
|
|
883
|
-
detailId = null;
|
|
884
|
-
}
|
|
885
|
-
refreshRows();
|
|
957
|
+
stopDetailTimer();
|
|
958
|
+
const after = listRows();
|
|
959
|
+
const nextId = neighbourCloseTarget(before, closedIdx, row.navigatorId, after);
|
|
886
960
|
onClosed();
|
|
887
|
-
|
|
961
|
+
if (!nextId) {
|
|
962
|
+
// Nothing left to show: hand the keyboard back to the editor.
|
|
963
|
+
unfocusMainList();
|
|
964
|
+
close();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
state().mainListSelectedId = nextId;
|
|
968
|
+
overlayState.rows = after;
|
|
969
|
+
overlayState.selected = Math.max(0, after.findIndex((candidate) => candidate.navigatorId === nextId));
|
|
970
|
+
refreshMainListWidget();
|
|
971
|
+
openDetail();
|
|
888
972
|
return;
|
|
889
973
|
}
|
|
890
974
|
clearCloseArm();
|
|
@@ -900,21 +984,42 @@ function createOverlayComponent(
|
|
|
900
984
|
requestRender();
|
|
901
985
|
}
|
|
902
986
|
|
|
987
|
+
let editorRendersSeen: number | undefined;
|
|
988
|
+
/**
|
|
989
|
+
* Our editor wrapper was uninstalled: close, since no key of ours can reach this overlay again and
|
|
990
|
+
* it would cover the editor that replaced ours (#332). An extension that composes the editor inside
|
|
991
|
+
* its own non-container component still renders ours, so the wrapper also counts as live while it
|
|
992
|
+
* rendered since the previous overlay frame (Pi renders the editor before it composites overlays).
|
|
993
|
+
*/
|
|
994
|
+
function closeIfEditorReplaced(): void {
|
|
995
|
+
const rendersBefore = editorRendersSeen;
|
|
996
|
+
const renders = state().editorRenders ?? 0;
|
|
997
|
+
editorRendersSeen = renders;
|
|
998
|
+
state().editorRenderedLastFrame = rendersBefore === undefined ? undefined : renders !== rendersBefore;
|
|
999
|
+
if (closed || liveEditorMounted(tui) !== false || rendersBefore === undefined || renders !== rendersBefore) return;
|
|
1000
|
+
// Not from inside pi-tui's render pass: hiding an overlay there would change the stack it is drawing.
|
|
1001
|
+
queueMicrotask(() => {
|
|
1002
|
+
if (closed) return;
|
|
1003
|
+
unfocusMainList();
|
|
1004
|
+
close();
|
|
1005
|
+
requestRender();
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
903
1009
|
return {
|
|
904
1010
|
render(width: number) {
|
|
1011
|
+
closeIfEditorReplaced();
|
|
905
1012
|
refreshRows();
|
|
906
|
-
const railLines =
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
: [];
|
|
912
|
-
const editorLines = mode === "detail" ? renderEditorLines(width) : [];
|
|
1013
|
+
const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
1014
|
+
selectedId: selectedRow()?.navigatorId,
|
|
1015
|
+
focused: true,
|
|
1016
|
+
});
|
|
1017
|
+
const editorLines = renderEditorLines(width);
|
|
913
1018
|
const bottomLines = [...railLines, ...editorLines];
|
|
914
1019
|
const overlayRows = state().detailOverlayRows;
|
|
915
1020
|
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
916
1021
|
let contentLines: string[];
|
|
917
|
-
if (
|
|
1022
|
+
if (detail?.transcript && deps.createTranscriptComponent) {
|
|
918
1023
|
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
919
1024
|
transcriptDetail = detail;
|
|
920
1025
|
transcriptComponent = deps.createTranscriptComponent(detail, theme);
|
|
@@ -930,15 +1035,13 @@ function createOverlayComponent(
|
|
|
930
1035
|
} else {
|
|
931
1036
|
transcriptDetail = null;
|
|
932
1037
|
transcriptComponent = null;
|
|
933
|
-
contentLines =
|
|
934
|
-
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false })
|
|
935
|
-
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
1038
|
+
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
|
|
936
1039
|
}
|
|
937
|
-
if (
|
|
938
|
-
if (detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
1040
|
+
if (overlayRows === undefined || detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
939
1041
|
const fittedContent = contentLines.slice(0, detailRows);
|
|
940
1042
|
while (fittedContent.length < detailRows) fittedContent.push("");
|
|
941
|
-
|
|
1043
|
+
// A terminal shorter than rail + editor keeps the bottom rows (the input frame) and drops the top.
|
|
1044
|
+
return [...fittedContent, ...bottomLines].slice(-Math.max(1, overlayRows));
|
|
942
1045
|
},
|
|
943
1046
|
handleInput(data: string) {
|
|
944
1047
|
if (closed) return;
|
|
@@ -946,56 +1049,53 @@ function createOverlayComponent(
|
|
|
946
1049
|
handleCloseKey();
|
|
947
1050
|
return;
|
|
948
1051
|
}
|
|
949
|
-
if (
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
else expandedSections.add(sectionId);
|
|
971
|
-
requestRender();
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
else if (data === "l" || data === "L") {
|
|
975
|
-
logTailRows = cycleLogTailRows(logTailRows);
|
|
976
|
-
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
1052
|
+
if (deps.matchKey(data, "up")) {
|
|
1053
|
+
selectOverlayRow(overlayState.selected - 1);
|
|
1054
|
+
activateSelectedRow();
|
|
1055
|
+
}
|
|
1056
|
+
else if (deps.matchKey(data, "down")) {
|
|
1057
|
+
selectOverlayRow(overlayState.selected + 1);
|
|
1058
|
+
activateSelectedRow();
|
|
1059
|
+
}
|
|
1060
|
+
else if (deps.matchKey(data, "left")) {
|
|
1061
|
+
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
1062
|
+
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
1063
|
+
close();
|
|
1064
|
+
}
|
|
1065
|
+
else if (deps.matchKey(data, "enter")) {
|
|
1066
|
+
const row = selectedRow();
|
|
1067
|
+
if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
|
|
1068
|
+
else {
|
|
1069
|
+
const sectionId = firstToggleableSectionId(detail);
|
|
1070
|
+
if (!sectionId) return;
|
|
1071
|
+
if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
|
|
1072
|
+
else expandedSections.add(sectionId);
|
|
977
1073
|
requestRender();
|
|
978
1074
|
}
|
|
979
|
-
else if (deps.matchKey(data, "escape")) {
|
|
980
|
-
unfocusMainList();
|
|
981
|
-
close();
|
|
982
|
-
}
|
|
983
|
-
return;
|
|
984
1075
|
}
|
|
985
|
-
if (
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
requestRender();
|
|
989
|
-
} else if (deps.matchKey(data, "down")) {
|
|
990
|
-
overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), overlayState.selected + 1);
|
|
991
|
-
clearCloseArm();
|
|
1076
|
+
else if (data === "l" || data === "L") {
|
|
1077
|
+
logTailRows = cycleLogTailRows(logTailRows);
|
|
1078
|
+
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
992
1079
|
requestRender();
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
|
|
1080
|
+
}
|
|
1081
|
+
else if (deps.matchKey(data, "escape")) {
|
|
1082
|
+
unfocusMainList();
|
|
996
1083
|
close();
|
|
997
1084
|
}
|
|
998
1085
|
},
|
|
1086
|
+
/** Show another row's detail in this overlay (the main list moved while it was unfocused). */
|
|
1087
|
+
showDetail(navigatorId: string) {
|
|
1088
|
+
if (closed) return;
|
|
1089
|
+
refreshRows();
|
|
1090
|
+
const idx = overlayState.rows.findIndex((row) => row.navigatorId === navigatorId);
|
|
1091
|
+
if (idx < 0) return;
|
|
1092
|
+
overlayState.selected = idx;
|
|
1093
|
+
state().mainListSelectedId = navigatorId;
|
|
1094
|
+
if (overlayState.rows[idx]!.parentRow) close();
|
|
1095
|
+
else openDetail();
|
|
1096
|
+
},
|
|
1097
|
+
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1098
|
+
dismiss() { close(); },
|
|
999
1099
|
invalidate() { transcriptComponent?.invalidate(); },
|
|
1000
1100
|
dispose() {
|
|
1001
1101
|
clearCloseArm();
|
|
@@ -1004,6 +1104,30 @@ function createOverlayComponent(
|
|
|
1004
1104
|
};
|
|
1005
1105
|
}
|
|
1006
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* After a confirmed close, the row whose detail the overlay shows next: the
|
|
1109
|
+
* nearest closable row below the closed one in the pre-close order, else the
|
|
1110
|
+
* nearest above it. `undefined` means nothing closable remains.
|
|
1111
|
+
*/
|
|
1112
|
+
function neighbourCloseTarget(
|
|
1113
|
+
before: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
|
|
1114
|
+
closedIdx: number,
|
|
1115
|
+
closedId: string,
|
|
1116
|
+
after: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
|
|
1117
|
+
): string | undefined {
|
|
1118
|
+
const remaining = new Set(after.filter((row) => !row.parentRow && row.navigatorId !== closedId).map((row) => row.navigatorId));
|
|
1119
|
+
for (let i = closedIdx + 1; i < before.length; i += 1) {
|
|
1120
|
+
const id = before[i]?.navigatorId;
|
|
1121
|
+
if (id && remaining.has(id)) return id;
|
|
1122
|
+
}
|
|
1123
|
+
for (let i = Math.min(closedIdx, before.length) - 1; i >= 0; i -= 1) {
|
|
1124
|
+
const id = before[i]?.navigatorId;
|
|
1125
|
+
if (id && remaining.has(id)) return id;
|
|
1126
|
+
}
|
|
1127
|
+
// Rows that appeared since the last render.
|
|
1128
|
+
return after.find((row) => remaining.has(row.navigatorId))?.navigatorId;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1007
1131
|
function buildTranscriptDetailLines(
|
|
1008
1132
|
detail: BackgroundWorkDetail,
|
|
1009
1133
|
transcriptLines: string[],
|
|
@@ -1088,33 +1212,6 @@ function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
|
|
|
1088
1212
|
};
|
|
1089
1213
|
}
|
|
1090
1214
|
|
|
1091
|
-
function buildListLines(nav: OverlayState, width: number, truncate: (s: string, width: number) => string, fg: (color: string, value: string) => string): string[] {
|
|
1092
|
-
const lines: string[] = [];
|
|
1093
|
-
lines.push(rule(`Work · ${nav.rows.length}`, width));
|
|
1094
|
-
const selected = nav.rows[nav.selected];
|
|
1095
|
-
const closeAction = selected ? "x close" : null;
|
|
1096
|
-
lines.push(dim(` ${["↑↓ select", "Enter view", closeAction, "Esc close"].filter(Boolean).join(" · ")}`, fg));
|
|
1097
|
-
lines.push("");
|
|
1098
|
-
if (nav.rows.length === 0) lines.push(" (no work)");
|
|
1099
|
-
let lastProvider = "";
|
|
1100
|
-
for (let i = 0; i < nav.rows.length; i += 1) {
|
|
1101
|
-
const row = nav.rows[i]!;
|
|
1102
|
-
if (row.providerLabel !== lastProvider) {
|
|
1103
|
-
lines.push(dim(section(row.providerLabel, width), fg));
|
|
1104
|
-
lastProvider = row.providerLabel;
|
|
1105
|
-
}
|
|
1106
|
-
const prefix = i === nav.selected ? fg("accent", "› ") : " ";
|
|
1107
|
-
const status = fg(toneColor(row.statusTone, row.status), row.status);
|
|
1108
|
-
const facts = (row.facts ?? []).filter(Boolean).slice(0, 2);
|
|
1109
|
-
const suffix = facts.length ? ` · ${facts.join(" · ")}` : "";
|
|
1110
|
-
lines.push(`${prefix}${row.name || row.id} · ${row.kind} · ${row.elapsed} · ${row.primary} · ${status}${suffix}`);
|
|
1111
|
-
if (row.secondary) lines.push(` ${row.secondary}`);
|
|
1112
|
-
}
|
|
1113
|
-
lines.push("");
|
|
1114
|
-
lines.push(dim(rule("", width), fg));
|
|
1115
|
-
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
1215
|
function buildDetailLines(
|
|
1119
1216
|
detail: BackgroundWorkDetail | null,
|
|
1120
1217
|
width: number,
|
|
@@ -1173,7 +1270,9 @@ function buildDetailLines(
|
|
|
1173
1270
|
lines.push(` ${preview} ${folded}`);
|
|
1174
1271
|
} else {
|
|
1175
1272
|
const wrapped = wrapEvidenceText(body, width - 6);
|
|
1176
|
-
|
|
1273
|
+
let shown = wrapped.slice(0, tailRows);
|
|
1274
|
+
// Same budget as the tail branch: the header row and the trailing blank come out of maxRows too.
|
|
1275
|
+
if (options.maxRows !== undefined) shown = shown.slice(0, Math.max(1, options.maxRows - lines.length - 2));
|
|
1177
1276
|
lines.push(dim(section(`${detail.evidence.label} · showing ${shown.length}/${wrapped.length} rows`, width), fg));
|
|
1178
1277
|
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
1179
1278
|
}
|