pi-better-background-tasks 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/package.json +1 -1
- package/src/failures.ts +85 -3
- package/src/index.ts +5 -0
- package/src/navigator-provider.ts +11 -7
- package/src/output.ts +83 -49
- package/src/runtime.ts +189 -17
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +34 -3
- package/src/shared-failure-observations.ts +414 -47
- package/src/shared-log-utils.ts +108 -0
- package/src/shared-navigator.ts +180 -134
- package/src/shared-sandbox-core.ts +724 -17
- package/src/tools.ts +107 -32
- package/src/types.ts +7 -0
package/src/shared-log-utils.ts
CHANGED
|
@@ -798,6 +798,114 @@ export function revisionOf(value: unknown): string {
|
|
|
798
798
|
return createHash("sha256").update(JSON.stringify(value) ?? "undefined").digest("base64url").slice(0, 32);
|
|
799
799
|
}
|
|
800
800
|
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// Shared output-control parameters, session scope keys, and log identity facts
|
|
803
|
+
// (#321/#323). Subagent and background-task tools both read these, so the two
|
|
804
|
+
// families cannot drift.
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Canonical output-control parameter names and their deprecated aliases.
|
|
809
|
+
* Canonical names follow this repo's dominant snake_case tool-parameter style
|
|
810
|
+
* (`max_log_bytes`, `timeout_seconds`, `exclude_tools`, `sandbox_dir`, ...).
|
|
811
|
+
*/
|
|
812
|
+
export const OUTPUT_CONTROL_ALIASES = {
|
|
813
|
+
max_bytes: "maxBytes",
|
|
814
|
+
lines: "tail_lines",
|
|
815
|
+
} as const;
|
|
816
|
+
|
|
817
|
+
export interface OutputControls {
|
|
818
|
+
/** Raw requested byte budget (canonical `max_bytes`, else deprecated `maxBytes`). */
|
|
819
|
+
maxBytes?: unknown;
|
|
820
|
+
/** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
|
|
821
|
+
lines?: unknown;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Resolve output-control parameters. Both spellings work; when both are given
|
|
826
|
+
* the canonical name wins. An explicit `null` is "not given", so a null
|
|
827
|
+
* canonical value never hides an alias value (#332). Values are returned
|
|
828
|
+
* unvalidated so each surface keeps its own defaults and hard caps.
|
|
829
|
+
*/
|
|
830
|
+
export function readOutputControls(params: unknown): OutputControls {
|
|
831
|
+
const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
|
|
832
|
+
const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
|
|
833
|
+
const maxBytes = pick("max_bytes");
|
|
834
|
+
const lines = pick("lines");
|
|
835
|
+
return {
|
|
836
|
+
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
837
|
+
...(lines !== undefined ? { lines } : {}),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Explicit opt-in sections for otherwise-omitted spend and tool facts. */
|
|
842
|
+
export const OUTPUT_INCLUDE_VALUES = ["cost", "tools"] as const;
|
|
843
|
+
export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
|
|
844
|
+
|
|
845
|
+
/** Parse an `include` request into known values and unknown ones (both deduplicated). */
|
|
846
|
+
export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
|
|
847
|
+
// Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
|
|
848
|
+
const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
|
|
849
|
+
const include = new Set<OutputInclude>();
|
|
850
|
+
const unknown: string[] = [];
|
|
851
|
+
for (const entry of raw) {
|
|
852
|
+
const name = String(entry ?? "").trim().toLowerCase();
|
|
853
|
+
if (!name) continue;
|
|
854
|
+
if ((OUTPUT_INCLUDE_VALUES as readonly string[]).includes(name)) include.add(name as OutputInclude);
|
|
855
|
+
else if (!unknown.includes(name)) unknown.push(name);
|
|
856
|
+
}
|
|
857
|
+
return { include, unknown };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Session identity that a read or mutation is scoped to. */
|
|
861
|
+
export interface ScopeOrigin {
|
|
862
|
+
cwd: string;
|
|
863
|
+
sessionId?: string;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Stable digest of one cwd + session origin (the digest the subagent registry indexes by). */
|
|
867
|
+
export function originScopeDigest(origin: ScopeOrigin): string {
|
|
868
|
+
return createHash("sha256").update(origin.cwd).update("\0").update(origin.sessionId ?? "").digest("hex").slice(0, 24);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Cursor scope. Every pagination and revision cursor binds this key, so a
|
|
873
|
+
* cursor never crosses session scopes. `unavailable` wins over an origin: a
|
|
874
|
+
* caller whose session identity could not be read is never the same scope as
|
|
875
|
+
* a readable one.
|
|
876
|
+
*/
|
|
877
|
+
export function sessionScopeKey(input: {
|
|
878
|
+
all?: boolean;
|
|
879
|
+
unavailable?: boolean;
|
|
880
|
+
origin?: ScopeOrigin;
|
|
881
|
+
/** Key when there is no origin at all (for example, a per-process scope). */
|
|
882
|
+
fallback?: string;
|
|
883
|
+
}): string {
|
|
884
|
+
if (input.all) return "all";
|
|
885
|
+
if (input.unavailable) return "session:unavailable";
|
|
886
|
+
if (input.origin) return `session:${originScopeDigest(input.origin)}`;
|
|
887
|
+
return input.fallback ?? "none";
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Identity facts of a retained log for a content revision: device, inode,
|
|
892
|
+
* size, and whole-millisecond mtime, or the read error. A deleted, replaced,
|
|
893
|
+
* or appended log is a content change.
|
|
894
|
+
*/
|
|
895
|
+
export function logIdentityFacts(path: string): unknown {
|
|
896
|
+
try {
|
|
897
|
+
const stats = statSync(path);
|
|
898
|
+
return [stats.dev, stats.ino, stats.size, Math.trunc(stats.mtimeMs)];
|
|
899
|
+
} catch (error) {
|
|
900
|
+
return ["unreadable", (error as NodeJS.ErrnoException).code ?? String(error)];
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Content revision: consumer lifecycle facts followed by the retained log's identity. */
|
|
905
|
+
export function lifecycleContentRevision(facts: readonly unknown[], logPath: string): string {
|
|
906
|
+
return revisionOf([...facts, logIdentityFacts(logPath)]);
|
|
907
|
+
}
|
|
908
|
+
|
|
801
909
|
// ---------------------------------------------------------------------------
|
|
802
910
|
// Row pages (lists). Keyset cursors keep paging stable while new rows arrive.
|
|
803
911
|
// ---------------------------------------------------------------------------
|
package/src/shared-navigator.ts
CHANGED
|
@@ -93,8 +93,12 @@ 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;
|
|
96
98
|
detailOverlayRows?: number;
|
|
97
99
|
dispose?: () => void;
|
|
100
|
+
/** The mounted detail overlay, reused instead of stacking a second one. */
|
|
101
|
+
overlay?: { showDetail(navigatorId: string): void; focus(): void };
|
|
98
102
|
};
|
|
99
103
|
|
|
100
104
|
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
@@ -106,7 +110,7 @@ export const CLOSE_CONFIRM_STATUS_KEY = "background-work-close";
|
|
|
106
110
|
export const MAIN_LIST_WIDGET_KEY = "background-work-list";
|
|
107
111
|
export const DETAIL_TICK_MS = 10_000;
|
|
108
112
|
export const CLOSE_ARM_MS = 3000;
|
|
109
|
-
export const DEFAULT_LOG_TAIL_ROWS =
|
|
113
|
+
export const DEFAULT_LOG_TAIL_ROWS = 25;
|
|
110
114
|
export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
|
|
111
115
|
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
112
116
|
const DETAIL_OVERLAY_BOTTOM_MARGIN_ROWS = 0;
|
|
@@ -199,6 +203,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
199
203
|
const s = state();
|
|
200
204
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
201
205
|
s.dispose = undefined;
|
|
206
|
+
s.overlay = undefined;
|
|
202
207
|
stopMainListWidget();
|
|
203
208
|
const activeCtx = ctx ?? s.uiCtx;
|
|
204
209
|
s.uiCtx = undefined;
|
|
@@ -542,7 +547,10 @@ function stableFacts(facts: string[] | undefined): string[] {
|
|
|
542
547
|
|
|
543
548
|
function rowSummary(row: InternalRow): string {
|
|
544
549
|
if (row.providerId === "subagents") {
|
|
550
|
+
const attention = row.facts?.map(singleLine).filter(Boolean);
|
|
551
|
+
if (attention?.length) return `${singleLine(row.status)} · ${attention.join(" · ")}`;
|
|
545
552
|
const parts: string[] = [];
|
|
553
|
+
if (row.statusTone !== "running" && row.kind !== "main agent") parts.push(singleLine(row.status));
|
|
546
554
|
if (row.model) parts.push(row.effort ? `${singleLine(row.model)} ${singleLine(row.effort)}` : singleLine(row.model));
|
|
547
555
|
if (row.tool) parts.push(`tool ${singleLine(row.tool)}`);
|
|
548
556
|
if (row.tokens) parts.push(singleLine(row.tokens));
|
|
@@ -624,7 +632,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
624
632
|
|
|
625
633
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
626
634
|
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
627
|
-
|
|
635
|
+
const proxy = new Proxy(inner, {
|
|
628
636
|
get(target, prop) {
|
|
629
637
|
if (prop === "handleInput") {
|
|
630
638
|
return (data: string) => {
|
|
@@ -641,12 +649,24 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
|
641
649
|
return Reflect.set(target, prop, value);
|
|
642
650
|
},
|
|
643
651
|
});
|
|
652
|
+
// Pi mounts and focuses whatever the factory returns; an extension that
|
|
653
|
+
// re-installs the editor replaces it, so this always names the live one.
|
|
654
|
+
state().editorFocusTarget = proxy;
|
|
655
|
+
return proxy;
|
|
644
656
|
}
|
|
645
657
|
|
|
646
658
|
function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
647
659
|
const s = state();
|
|
648
660
|
const rows = listRows();
|
|
649
|
-
if (rows.length === 0)
|
|
661
|
+
if (rows.length === 0) {
|
|
662
|
+
// Every row is gone, but an overlay that lost focus may still be mounted: Esc still closes it.
|
|
663
|
+
if (s.overlay && deps.matchKey(data, "escape")) {
|
|
664
|
+
unfocusMainList();
|
|
665
|
+
dismissOverlay();
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
650
670
|
if (!s.mainListFocused) {
|
|
651
671
|
if (!deps.isOpenTrigger(data)) return false;
|
|
652
672
|
focusMainList();
|
|
@@ -670,7 +690,10 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
670
690
|
}
|
|
671
691
|
if (deps.matchKey(data, "enter")) {
|
|
672
692
|
const selected = selectedMainListRow();
|
|
673
|
-
if (selected?.parentRow)
|
|
693
|
+
if (selected?.parentRow) {
|
|
694
|
+
unfocusMainList();
|
|
695
|
+
dismissOverlay();
|
|
696
|
+
}
|
|
674
697
|
else openNavigator();
|
|
675
698
|
return true;
|
|
676
699
|
}
|
|
@@ -680,11 +703,19 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
680
703
|
}
|
|
681
704
|
if (deps.matchKey(data, "escape") || deps.isOpenTrigger(data)) {
|
|
682
705
|
unfocusMainList();
|
|
706
|
+
dismissOverlay();
|
|
683
707
|
return true;
|
|
684
708
|
}
|
|
685
709
|
return false;
|
|
686
710
|
}
|
|
687
711
|
|
|
712
|
+
/** Close a detail overlay that is still mounted while the editor has focus. */
|
|
713
|
+
function dismissOverlay(): void {
|
|
714
|
+
const s = state();
|
|
715
|
+
if (!s.overlay) return;
|
|
716
|
+
try { s.dispose?.(); } catch { /* ignore */ }
|
|
717
|
+
}
|
|
718
|
+
|
|
688
719
|
function handleMainListCloseKey(): void {
|
|
689
720
|
const row = selectedMainListRow();
|
|
690
721
|
if (!row || row.parentRow) return;
|
|
@@ -718,20 +749,54 @@ function openNavigator(): void {
|
|
|
718
749
|
const rows = listRows();
|
|
719
750
|
if (rows.length === 0) return;
|
|
720
751
|
const selectedId = selectedMainListRow()?.navigatorId ?? rows[0]!.navigatorId;
|
|
752
|
+
// An overlay that lost focus (another UI took it and handed it to the editor)
|
|
753
|
+
// is still mounted: switch its detail and focus it rather than stacking a
|
|
754
|
+
// second overlay over it, whose Esc would only reveal the stale one below.
|
|
755
|
+
const mounted = s.overlay;
|
|
756
|
+
if (mounted) {
|
|
757
|
+
// showDetail may close the overlay (and clear s.overlay); focus() is then a no-op.
|
|
758
|
+
mounted.showDetail(selectedId);
|
|
759
|
+
mounted.focus();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
721
762
|
try {
|
|
722
763
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
723
764
|
s.dispose = undefined;
|
|
724
765
|
let disposeToken: (() => void) | undefined;
|
|
766
|
+
let overlayRef: NavigatorState["overlay"];
|
|
767
|
+
let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
|
|
768
|
+
const release = () => {
|
|
769
|
+
if (overlayRef && s.overlay === overlayRef) s.overlay = undefined;
|
|
770
|
+
};
|
|
725
771
|
const opened = (ctx.ui as any).custom((tui: any, theme: any, _keybindings: any, done: (v: null) => void) => {
|
|
726
|
-
const component = createOverlayComponent(rows, deps, tui, theme,
|
|
772
|
+
const component = createOverlayComponent(rows, deps, tui, theme, (value) => {
|
|
773
|
+
release();
|
|
774
|
+
// Pi restores focus to the editor that was focused when the overlay opened. If an
|
|
775
|
+
// extension re-installed the editor since, that instance is unmounted and every key
|
|
776
|
+
// would be lost; hand focus to the live editor before closing.
|
|
777
|
+
const liveEditor = s.editorFocusTarget;
|
|
778
|
+
if (liveEditor) {
|
|
779
|
+
try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
|
|
780
|
+
}
|
|
781
|
+
done(value);
|
|
782
|
+
}, () => {
|
|
727
783
|
s.lastHint = undefined;
|
|
728
784
|
refreshBackgroundWorkNavigator(ctx);
|
|
729
785
|
}, selectedId);
|
|
730
|
-
|
|
786
|
+
overlayRef = {
|
|
787
|
+
showDetail: (navigatorId) => component.showDetail(navigatorId),
|
|
788
|
+
focus: () => {
|
|
789
|
+
if (s.overlay !== overlayRef) return;
|
|
790
|
+
try { handle?.focus?.(); } catch { /* ignore */ }
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
s.overlay = overlayRef;
|
|
794
|
+
disposeToken = () => component.dismiss();
|
|
731
795
|
s.dispose = disposeToken;
|
|
732
796
|
return component;
|
|
733
|
-
}, { overlay: true, overlayOptions: detailOverlayOptions });
|
|
797
|
+
}, { overlay: true, overlayOptions: detailOverlayOptions, onHandle: (h: typeof handle) => { handle = h; } });
|
|
734
798
|
const clear = () => {
|
|
799
|
+
release();
|
|
735
800
|
if (s.dispose === disposeToken) s.dispose = undefined;
|
|
736
801
|
};
|
|
737
802
|
void Promise.resolve(opened).then(clear, clear);
|
|
@@ -747,16 +812,16 @@ function createOverlayComponent(
|
|
|
747
812
|
theme: { fg?(color: string, value: string): string } | undefined,
|
|
748
813
|
done: (v: null) => void,
|
|
749
814
|
onClosed: () => void,
|
|
750
|
-
initialDetailId
|
|
815
|
+
initialDetailId: string,
|
|
751
816
|
) {
|
|
817
|
+
// The overlay only ever shows one row's detail; the rail below it is the list.
|
|
752
818
|
const overlayState: OverlayState = { rows: initialRows, selected: 0 };
|
|
753
|
-
let mode: "list" | "detail" = initialDetailId ? "detail" : "list";
|
|
754
819
|
let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
|
|
755
820
|
const expandedSections = new Set<string>();
|
|
756
|
-
let detailId: string
|
|
757
|
-
let detail: BackgroundWorkDetail | null =
|
|
821
|
+
let detailId: string = initialDetailId;
|
|
822
|
+
let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
|
|
758
823
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
759
|
-
|
|
824
|
+
{
|
|
760
825
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
761
826
|
if (idx >= 0) overlayState.selected = idx;
|
|
762
827
|
}
|
|
@@ -773,7 +838,7 @@ function createOverlayComponent(
|
|
|
773
838
|
}
|
|
774
839
|
|
|
775
840
|
const detailScheduler = createRenderScheduler(() => {
|
|
776
|
-
if (
|
|
841
|
+
if (closed) return;
|
|
777
842
|
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
778
843
|
requestRender();
|
|
779
844
|
startDetailTimer();
|
|
@@ -834,29 +899,11 @@ function createOverlayComponent(
|
|
|
834
899
|
expandedSections.clear();
|
|
835
900
|
detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
|
|
836
901
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
837
|
-
mode = "detail";
|
|
838
902
|
startDetailTimer();
|
|
839
903
|
requestRender();
|
|
840
904
|
}
|
|
841
905
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
function leaveDetail(): void {
|
|
845
|
-
if (mode !== "detail") return;
|
|
846
|
-
const viewedId = detailId;
|
|
847
|
-
clearCloseArm();
|
|
848
|
-
stopDetailTimer();
|
|
849
|
-
mode = "list";
|
|
850
|
-
detail = null;
|
|
851
|
-
detailId = null;
|
|
852
|
-
expandedSections.clear();
|
|
853
|
-
refreshRows();
|
|
854
|
-
if (viewedId) {
|
|
855
|
-
const idx = overlayState.rows.findIndex((row) => row.navigatorId === viewedId);
|
|
856
|
-
if (idx >= 0) overlayState.selected = idx;
|
|
857
|
-
}
|
|
858
|
-
requestRender();
|
|
859
|
-
}
|
|
906
|
+
startDetailTimer();
|
|
860
907
|
|
|
861
908
|
function close(): void {
|
|
862
909
|
if (closed) return;
|
|
@@ -868,20 +915,28 @@ function createOverlayComponent(
|
|
|
868
915
|
|
|
869
916
|
function handleCloseKey(): void {
|
|
870
917
|
const row = selectedRow();
|
|
871
|
-
if (!row) return;
|
|
918
|
+
if (!row || row.parentRow) return;
|
|
872
919
|
const now = Date.now();
|
|
873
920
|
if (closeArm?.id === row.navigatorId && now >= closeArm.armedAt && now < closeArm.armedAt + CLOSE_ARM_MS) {
|
|
874
921
|
clearCloseArm();
|
|
922
|
+
const before = overlayState.rows;
|
|
923
|
+
const closedIdx = overlayState.selected;
|
|
875
924
|
closeFor(row);
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
detail = null;
|
|
880
|
-
detailId = null;
|
|
881
|
-
}
|
|
882
|
-
refreshRows();
|
|
925
|
+
stopDetailTimer();
|
|
926
|
+
const after = listRows();
|
|
927
|
+
const nextId = neighbourCloseTarget(before, closedIdx, row.navigatorId, after);
|
|
883
928
|
onClosed();
|
|
884
|
-
|
|
929
|
+
if (!nextId) {
|
|
930
|
+
// Nothing left to show: hand the keyboard back to the editor.
|
|
931
|
+
unfocusMainList();
|
|
932
|
+
close();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
state().mainListSelectedId = nextId;
|
|
936
|
+
overlayState.rows = after;
|
|
937
|
+
overlayState.selected = Math.max(0, after.findIndex((candidate) => candidate.navigatorId === nextId));
|
|
938
|
+
refreshMainListWidget();
|
|
939
|
+
openDetail();
|
|
885
940
|
return;
|
|
886
941
|
}
|
|
887
942
|
clearCloseArm();
|
|
@@ -900,18 +955,16 @@ function createOverlayComponent(
|
|
|
900
955
|
return {
|
|
901
956
|
render(width: number) {
|
|
902
957
|
refreshRows();
|
|
903
|
-
const railLines =
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
: [];
|
|
909
|
-
const editorLines = mode === "detail" ? renderEditorLines(width) : [];
|
|
958
|
+
const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
959
|
+
selectedId: selectedRow()?.navigatorId,
|
|
960
|
+
focused: true,
|
|
961
|
+
});
|
|
962
|
+
const editorLines = renderEditorLines(width);
|
|
910
963
|
const bottomLines = [...railLines, ...editorLines];
|
|
911
964
|
const overlayRows = state().detailOverlayRows;
|
|
912
965
|
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
913
966
|
let contentLines: string[];
|
|
914
|
-
if (
|
|
967
|
+
if (detail?.transcript && deps.createTranscriptComponent) {
|
|
915
968
|
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
916
969
|
transcriptDetail = detail;
|
|
917
970
|
transcriptComponent = deps.createTranscriptComponent(detail, theme);
|
|
@@ -927,15 +980,13 @@ function createOverlayComponent(
|
|
|
927
980
|
} else {
|
|
928
981
|
transcriptDetail = null;
|
|
929
982
|
transcriptComponent = null;
|
|
930
|
-
contentLines =
|
|
931
|
-
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, bottomFooter: false })
|
|
932
|
-
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
983
|
+
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
|
|
933
984
|
}
|
|
934
|
-
if (
|
|
935
|
-
if (detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
985
|
+
if (overlayRows === undefined || detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
936
986
|
const fittedContent = contentLines.slice(0, detailRows);
|
|
937
987
|
while (fittedContent.length < detailRows) fittedContent.push("");
|
|
938
|
-
|
|
988
|
+
// A terminal shorter than rail + editor keeps the bottom rows (the input frame) and drops the top.
|
|
989
|
+
return [...fittedContent, ...bottomLines].slice(-Math.max(1, overlayRows));
|
|
939
990
|
},
|
|
940
991
|
handleInput(data: string) {
|
|
941
992
|
if (closed) return;
|
|
@@ -943,56 +994,53 @@ function createOverlayComponent(
|
|
|
943
994
|
handleCloseKey();
|
|
944
995
|
return;
|
|
945
996
|
}
|
|
946
|
-
if (
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
else expandedSections.add(sectionId);
|
|
968
|
-
requestRender();
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
else if (data === "l" || data === "L") {
|
|
972
|
-
logTailRows = cycleLogTailRows(logTailRows);
|
|
973
|
-
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
997
|
+
if (deps.matchKey(data, "up")) {
|
|
998
|
+
selectOverlayRow(overlayState.selected - 1);
|
|
999
|
+
activateSelectedRow();
|
|
1000
|
+
}
|
|
1001
|
+
else if (deps.matchKey(data, "down")) {
|
|
1002
|
+
selectOverlayRow(overlayState.selected + 1);
|
|
1003
|
+
activateSelectedRow();
|
|
1004
|
+
}
|
|
1005
|
+
else if (deps.matchKey(data, "left")) {
|
|
1006
|
+
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
1007
|
+
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
1008
|
+
close();
|
|
1009
|
+
}
|
|
1010
|
+
else if (deps.matchKey(data, "enter")) {
|
|
1011
|
+
const row = selectedRow();
|
|
1012
|
+
if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
|
|
1013
|
+
else {
|
|
1014
|
+
const sectionId = firstToggleableSectionId(detail);
|
|
1015
|
+
if (!sectionId) return;
|
|
1016
|
+
if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
|
|
1017
|
+
else expandedSections.add(sectionId);
|
|
974
1018
|
requestRender();
|
|
975
1019
|
}
|
|
976
|
-
else if (deps.matchKey(data, "escape")) {
|
|
977
|
-
unfocusMainList();
|
|
978
|
-
close();
|
|
979
|
-
}
|
|
980
|
-
return;
|
|
981
1020
|
}
|
|
982
|
-
if (
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
requestRender();
|
|
986
|
-
} else if (deps.matchKey(data, "down")) {
|
|
987
|
-
overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), overlayState.selected + 1);
|
|
988
|
-
clearCloseArm();
|
|
1021
|
+
else if (data === "l" || data === "L") {
|
|
1022
|
+
logTailRows = cycleLogTailRows(logTailRows);
|
|
1023
|
+
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
989
1024
|
requestRender();
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
|
|
1025
|
+
}
|
|
1026
|
+
else if (deps.matchKey(data, "escape")) {
|
|
1027
|
+
unfocusMainList();
|
|
993
1028
|
close();
|
|
994
1029
|
}
|
|
995
1030
|
},
|
|
1031
|
+
/** Show another row's detail in this overlay (the main list moved while it was unfocused). */
|
|
1032
|
+
showDetail(navigatorId: string) {
|
|
1033
|
+
if (closed) return;
|
|
1034
|
+
refreshRows();
|
|
1035
|
+
const idx = overlayState.rows.findIndex((row) => row.navigatorId === navigatorId);
|
|
1036
|
+
if (idx < 0) return;
|
|
1037
|
+
overlayState.selected = idx;
|
|
1038
|
+
state().mainListSelectedId = navigatorId;
|
|
1039
|
+
if (overlayState.rows[idx]!.parentRow) close();
|
|
1040
|
+
else openDetail();
|
|
1041
|
+
},
|
|
1042
|
+
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1043
|
+
dismiss() { close(); },
|
|
996
1044
|
invalidate() { transcriptComponent?.invalidate(); },
|
|
997
1045
|
dispose() {
|
|
998
1046
|
clearCloseArm();
|
|
@@ -1001,6 +1049,30 @@ function createOverlayComponent(
|
|
|
1001
1049
|
};
|
|
1002
1050
|
}
|
|
1003
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* After a confirmed close, the row whose detail the overlay shows next: the
|
|
1054
|
+
* nearest closable row below the closed one in the pre-close order, else the
|
|
1055
|
+
* nearest above it. `undefined` means nothing closable remains.
|
|
1056
|
+
*/
|
|
1057
|
+
function neighbourCloseTarget(
|
|
1058
|
+
before: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
|
|
1059
|
+
closedIdx: number,
|
|
1060
|
+
closedId: string,
|
|
1061
|
+
after: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
|
|
1062
|
+
): string | undefined {
|
|
1063
|
+
const remaining = new Set(after.filter((row) => !row.parentRow && row.navigatorId !== closedId).map((row) => row.navigatorId));
|
|
1064
|
+
for (let i = closedIdx + 1; i < before.length; i += 1) {
|
|
1065
|
+
const id = before[i]?.navigatorId;
|
|
1066
|
+
if (id && remaining.has(id)) return id;
|
|
1067
|
+
}
|
|
1068
|
+
for (let i = Math.min(closedIdx, before.length) - 1; i >= 0; i -= 1) {
|
|
1069
|
+
const id = before[i]?.navigatorId;
|
|
1070
|
+
if (id && remaining.has(id)) return id;
|
|
1071
|
+
}
|
|
1072
|
+
// Rows that appeared since the last render.
|
|
1073
|
+
return after.find((row) => remaining.has(row.navigatorId))?.navigatorId;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1004
1076
|
function buildTranscriptDetailLines(
|
|
1005
1077
|
detail: BackgroundWorkDetail,
|
|
1006
1078
|
transcriptLines: string[],
|
|
@@ -1024,14 +1096,11 @@ function buildTranscriptDetailLines(
|
|
|
1024
1096
|
];
|
|
1025
1097
|
const transcriptHeader = ["", dim(section(`transcript · latest ${tailRows} rows`, width), fg)];
|
|
1026
1098
|
if (detail.transcriptDiagnostic) transcriptHeader.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
|
|
1099
|
+
// Metadata always renders; the transcript tail takes only the rows left over (the tail size is a cap, not a guarantee).
|
|
1100
|
+
lines.push(...optionalMetadata);
|
|
1027
1101
|
if (options.maxRows !== undefined) {
|
|
1028
|
-
const
|
|
1029
|
-
const transcriptBudget = Math.max(1, options.maxRows - fixedRows);
|
|
1102
|
+
const transcriptBudget = Math.max(1, options.maxRows - lines.length - transcriptHeader.length - 1);
|
|
1030
1103
|
shownTranscriptLines = shownTranscriptLines.slice(-transcriptBudget);
|
|
1031
|
-
const metadataBudget = Math.max(0, options.maxRows - fixedRows - shownTranscriptLines.length);
|
|
1032
|
-
lines.push(...optionalMetadata.slice(0, metadataBudget));
|
|
1033
|
-
} else {
|
|
1034
|
-
lines.push(...optionalMetadata);
|
|
1035
1104
|
}
|
|
1036
1105
|
lines.push(...transcriptHeader);
|
|
1037
1106
|
lines.push(...shownTranscriptLines);
|
|
@@ -1088,39 +1157,12 @@ function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
|
|
|
1088
1157
|
};
|
|
1089
1158
|
}
|
|
1090
1159
|
|
|
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
1160
|
function buildDetailLines(
|
|
1119
1161
|
detail: BackgroundWorkDetail | null,
|
|
1120
1162
|
width: number,
|
|
1121
1163
|
truncate: (s: string, width: number) => string,
|
|
1122
1164
|
fg: (color: string, value: string) => string,
|
|
1123
|
-
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; bottomFooter?: boolean } = {},
|
|
1165
|
+
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; maxRows?: number; bottomFooter?: boolean } = {},
|
|
1124
1166
|
): string[] {
|
|
1125
1167
|
if (!detail) {
|
|
1126
1168
|
const lines = [rule("Work unavailable", width), dim(" ← back · Esc close", fg), ""];
|
|
@@ -1173,7 +1215,9 @@ function buildDetailLines(
|
|
|
1173
1215
|
lines.push(` ${preview} ${folded}`);
|
|
1174
1216
|
} else {
|
|
1175
1217
|
const wrapped = wrapEvidenceText(body, width - 6);
|
|
1176
|
-
|
|
1218
|
+
let shown = wrapped.slice(0, tailRows);
|
|
1219
|
+
// Same budget as the tail branch: the header row and the trailing blank come out of maxRows too.
|
|
1220
|
+
if (options.maxRows !== undefined) shown = shown.slice(0, Math.max(1, options.maxRows - lines.length - 2));
|
|
1177
1221
|
lines.push(dim(section(`${detail.evidence.label} · showing ${shown.length}/${wrapped.length} rows`, width), fg));
|
|
1178
1222
|
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
1179
1223
|
}
|
|
@@ -1184,7 +1228,9 @@ function buildDetailLines(
|
|
|
1184
1228
|
const wrapped = /log/i.test(detail.evidence.label)
|
|
1185
1229
|
? wrapLogText(body, width - 6)
|
|
1186
1230
|
: wrapEvidenceText(body, width - 6);
|
|
1187
|
-
|
|
1231
|
+
let shown = tailEvidence ? wrapped.slice(-tailRows) : wrapped;
|
|
1232
|
+
// Metadata above always renders; a tail that outgrows the remaining rows drops its oldest rows so the newest stay on screen (+1 for the trailing blank).
|
|
1233
|
+
if (tailEvidence && options.maxRows !== undefined) shown = shown.slice(-Math.max(1, options.maxRows - lines.length - 1));
|
|
1188
1234
|
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
1189
1235
|
}
|
|
1190
1236
|
lines.push("");
|