@groeponline/pi-wishcraft 1.4.0 → 1.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/AGENTS.md +28 -0
- package/CHANGELOG.md +25 -0
- package/README.md +22 -18
- package/ROADMAP.md +3 -3
- package/STRATEGY.md +54 -0
- package/bash-mode/editor-input.ts +3 -1
- package/bash-mode/editor.ts +44 -7
- package/bash-mode/forward.ts +64 -0
- package/bash-mode/pty-session.ts +356 -0
- package/bash-mode/ptyshell-managed.ts +145 -0
- package/bash-mode/session-factory.ts +64 -0
- package/bash-mode/transcript-v2.ts +77 -0
- package/bash-mode/transcript.ts +18 -0
- package/bash-mode/types.ts +27 -0
- package/docs/bash-mode.md +4 -0
- package/docs/configuration.md +45 -1
- package/docs/index.md +4 -1
- package/package.json +1 -1
- package/src/config/effective.ts +122 -0
- package/src/config/settings-registry.ts +2 -0
- package/src/config/structural-presets.ts +1 -1
- package/src/extension/commands/bash-mode-actions.ts +11 -10
- package/src/extension/commands/commands.ts +8 -0
- package/src/extension/core/state.ts +0 -1
- package/src/extension/core/types.ts +2 -2
- package/src/extension/session/session-lifecycle.ts +21 -0
- package/src/extension/skills/skill-templates.ts +12 -3
- package/src/extension/ui/custom-editor.ts +4 -0
- package/src/extension/ui/status-line-renderers.ts +6 -7
- package/src/extension/welcome/welcome-integration.ts +23 -1
- package/src/motion/frames.ts +19 -9
- package/src/render/layout.ts +95 -0
- package/src/render/motion-rail.ts +79 -0
- package/src/render/paint.ts +40 -0
- package/src/render/v2-adapter.ts +68 -0
- package/src/render/v2-entry.ts +132 -0
- package/src/segments/pipeline.ts +164 -0
- package/src/segments/types.ts +36 -0
- package/src/segments/usage.ts +42 -3
- package/src/studio/actions.ts +105 -0
- package/src/studio/advice-pane.ts +73 -0
- package/src/studio/advise/context.ts +89 -0
- package/src/studio/advise/engine.ts +117 -0
- package/src/studio/advise/prompts.ts +68 -0
- package/src/studio/component.ts +105 -0
- package/src/studio/deepwiki/cache.ts +74 -0
- package/src/studio/deepwiki/client.ts +73 -0
- package/src/studio/deepwiki/extract.ts +66 -0
- package/src/studio/inspect.ts +38 -0
- package/src/studio/list.ts +48 -0
- package/src/studio/open.ts +44 -0
- package/src/studio/state.ts +100 -0
- package/src/studio/types.ts +30 -0
- package/src/welcome/banner.ts +6 -0
- package/src/welcome/index.ts +7 -0
- package/src/welcome/overlay.ts +6 -0
- package/src/welcome/renderer.ts +6 -39
- package/src/welcome/types.ts +6 -0
- package/src/welcome/welcome-art.ts +148 -0
- package/src/working-vibes/frames.ts +25 -0
- package/src/working-vibes/index.ts +2 -0
- package/src/working-vibes/storage.ts +11 -0
- package/bash-mode/shell-session.ts +0 -294
- package/src/signal/render.ts +0 -221
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Pure layout for the v2 powerline. No fs, no git, no theme reload.
|
|
2
|
+
// ponytail: this is a refactor target. The legacy getResponsiveLayout stays
|
|
3
|
+
// in place until U12 cutover; the v2 entry point is the only public surface
|
|
4
|
+
// for the new pipeline. Width classes follow the existing renderer
|
|
5
|
+
// (status-line-renderers.ts): small <60, medium <120, wide otherwise.
|
|
6
|
+
|
|
7
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
export type WidthClass = "small" | "medium" | "wide";
|
|
10
|
+
|
|
11
|
+
export interface LayoutSegment {
|
|
12
|
+
id: string;
|
|
13
|
+
text: string;
|
|
14
|
+
priority: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LayoutConfig {
|
|
18
|
+
primary: string[];
|
|
19
|
+
secondary: string[];
|
|
20
|
+
separator: string;
|
|
21
|
+
maxWidth: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LayoutResult {
|
|
25
|
+
primary: LayoutSegment[];
|
|
26
|
+
secondary: LayoutSegment[];
|
|
27
|
+
dropped: LayoutSegment[];
|
|
28
|
+
widthClass: WidthClass;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function widthClassFor(width: number): WidthClass {
|
|
32
|
+
if (width < 60) return "small";
|
|
33
|
+
if (width < 120) return "medium";
|
|
34
|
+
return "wide";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function laneFor(seg: LayoutSegment, config: LayoutConfig): "primary" | "secondary" | null {
|
|
38
|
+
if (config.primary.includes(seg.id)) return "primary";
|
|
39
|
+
if (config.secondary.includes(seg.id)) return "secondary";
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function computeLaneLayout(
|
|
44
|
+
segments: LayoutSegment[],
|
|
45
|
+
width: number,
|
|
46
|
+
config: LayoutConfig,
|
|
47
|
+
): LayoutResult {
|
|
48
|
+
const cap = Math.max(0, Math.min(width, config.maxWidth));
|
|
49
|
+
const separatorWidth = visibleWidth(config.separator);
|
|
50
|
+
const primary: LayoutSegment[] = [];
|
|
51
|
+
const secondary: LayoutSegment[] = [];
|
|
52
|
+
const dropped: LayoutSegment[] = [];
|
|
53
|
+
// Sort by priority desc once; then assign by lane membership and width.
|
|
54
|
+
const sorted = [...segments].sort((a, b) => b.priority - a.priority);
|
|
55
|
+
let primaryUsed = 0;
|
|
56
|
+
let secondaryUsed = 0;
|
|
57
|
+
for (const seg of sorted) {
|
|
58
|
+
const lane = laneFor(seg, config);
|
|
59
|
+
if (lane === null) {
|
|
60
|
+
dropped.push(seg);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (visibleWidth(seg.text) === 0) {
|
|
64
|
+
dropped.push(seg);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const w = visibleWidth(seg.text);
|
|
68
|
+
// Each lane owns its own budget: a primary segment must not push a
|
|
69
|
+
// secondary segment off the line. Separator width is charged only when
|
|
70
|
+
// a segment follows another in the same lane.
|
|
71
|
+
const used = lane === "primary" ? primaryUsed : secondaryUsed;
|
|
72
|
+
const addedWidth = used === 0 ? w : separatorWidth + w;
|
|
73
|
+
if (used + addedWidth > cap) {
|
|
74
|
+
dropped.push(seg);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
(lane === "primary" ? primary : secondary).push(seg);
|
|
78
|
+
if (lane === "primary") primaryUsed += addedWidth;
|
|
79
|
+
else secondaryUsed += addedWidth;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Honor the original primary/secondary order in the config so callers
|
|
83
|
+
// expect model->context->git->queue rather than priority-sorted.
|
|
84
|
+
const primaryOrder = new Map(config.primary.map((id, i) => [id, i]));
|
|
85
|
+
primary.sort((a, b) => (primaryOrder.get(a.id) ?? 0) - (primaryOrder.get(b.id) ?? 0));
|
|
86
|
+
const secondaryOrder = new Map(config.secondary.map((id, i) => [id, i]));
|
|
87
|
+
secondary.sort((a, b) => (secondaryOrder.get(a.id) ?? 0) - (secondaryOrder.get(b.id) ?? 0));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
primary,
|
|
91
|
+
secondary,
|
|
92
|
+
dropped,
|
|
93
|
+
widthClass: widthClassFor(width),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion rail (v2). Renders the Signal activity rail — the animated
|
|
3
|
+
* sweep/compact rail plus activity label — as a first-class layout segment.
|
|
4
|
+
* Extracted from the legacy v1 Signal renderer (src/signal/render.ts,
|
|
5
|
+
* deleted in the U12 cutover); rail semantics are unchanged: idle = flat
|
|
6
|
+
* rail, streaming = travelling head + trail, compacting = inward heads.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { sweepPosition, trailGlyph } from "../motion/index.ts";
|
|
10
|
+
import type { SignalRuntime } from "../signal/controller.ts";
|
|
11
|
+
import type { SignalSpec } from "../config/types.ts";
|
|
12
|
+
import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
|
|
13
|
+
|
|
14
|
+
export function renderActivity(
|
|
15
|
+
runtime: SignalRuntime,
|
|
16
|
+
spec: SignalSpec,
|
|
17
|
+
ascii = false,
|
|
18
|
+
): string {
|
|
19
|
+
const label = runtime.activity || "ready";
|
|
20
|
+
const open = spec.caps.leftOpen ?? "";
|
|
21
|
+
const close = spec.caps.leftClose ?? "";
|
|
22
|
+
const dim = getFgAnsiCode("sep");
|
|
23
|
+
const hot = getFgAnsiCode("accent");
|
|
24
|
+
const reset = colorEnabled() ? ansi.reset : "";
|
|
25
|
+
// One glyph family, directional comet: light `─` track, fixed solid
|
|
26
|
+
// head, and a short box-drawing trail ONLY behind the head. Idle is a
|
|
27
|
+
// calm flat rail (no cycling glyphs — the old shade-block cloud read
|
|
28
|
+
// muddy across three unrelated glyph families).
|
|
29
|
+
const RAIL_WIDTH = 12;
|
|
30
|
+
const track = ascii ? "-" : "─";
|
|
31
|
+
const head = ascii ? "o" : "●";
|
|
32
|
+
let railInner: string;
|
|
33
|
+
if (!runtime.active) {
|
|
34
|
+
railInner = track.repeat(RAIL_WIDTH);
|
|
35
|
+
} else if (runtime.activity === "compacting") {
|
|
36
|
+
// Compact state: two heads travel inward and compress a heavy core —
|
|
37
|
+
// visually distinct from the sweep so compaction reads at a glance.
|
|
38
|
+
railInner = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii);
|
|
39
|
+
} else {
|
|
40
|
+
const pos = sweepPosition(runtime.tick, RAIL_WIDTH, true);
|
|
41
|
+
const built: string[] = [];
|
|
42
|
+
for (let i = 0; i < RAIL_WIDTH; i++) {
|
|
43
|
+
if (i === pos) built.push(head);
|
|
44
|
+
else if (i < pos) built.push(trailGlyph(Math.min(pos - i, 4), ascii));
|
|
45
|
+
else built.push(track);
|
|
46
|
+
}
|
|
47
|
+
railInner = built.join("");
|
|
48
|
+
}
|
|
49
|
+
const railColor = runtime.active ? hot : dim;
|
|
50
|
+
const rail = `${spec.separators.left}${railInner}${spec.separators.right}`;
|
|
51
|
+
return `${railColor}${open}${rail}${close}${reset} ${label}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compact rail: two heads travel inward toward center, compressing the rail.
|
|
56
|
+
* Gives compaction a distinct visual signature vs the generic sweep.
|
|
57
|
+
*/
|
|
58
|
+
function renderCompactRail(
|
|
59
|
+
tick: number,
|
|
60
|
+
width: number,
|
|
61
|
+
ascii: boolean,
|
|
62
|
+
): string {
|
|
63
|
+
const half = Math.floor(width / 2);
|
|
64
|
+
// Heads oscillate from the edges toward center and back.
|
|
65
|
+
const span = half;
|
|
66
|
+
const phase = tick % (span * 2);
|
|
67
|
+
const inward = phase < span ? phase : span * 2 - phase;
|
|
68
|
+
const leftPos = inward;
|
|
69
|
+
const rightPos = width - 1 - inward;
|
|
70
|
+
const head = ascii ? "*" : "●";
|
|
71
|
+
const core = ascii ? "=" : "━";
|
|
72
|
+
const track = ascii ? "-" : "─";
|
|
73
|
+
let built = "";
|
|
74
|
+
for (let i = 0; i < width; i++) {
|
|
75
|
+
if (i === leftPos || i === rightPos) built += head;
|
|
76
|
+
else built += i > leftPos && i < rightPos ? core : track;
|
|
77
|
+
}
|
|
78
|
+
return built;
|
|
79
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Cheap paint assembly. Consumes a layout and produces terminal-ready
|
|
2
|
+
// strings. No fs, no git, no theme reload — those are upstream of layout.
|
|
3
|
+
|
|
4
|
+
export interface PaintedLane {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
bg: string;
|
|
8
|
+
fg: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PaintedLayout {
|
|
12
|
+
primary: PaintedLane[];
|
|
13
|
+
secondary: PaintedLane[];
|
|
14
|
+
dropped: PaintedLane[];
|
|
15
|
+
widthClass: "small" | "medium" | "wide";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RESET = "\x1b[0m";
|
|
19
|
+
|
|
20
|
+
export function paintLane(lane: PaintedLane): string {
|
|
21
|
+
return `${lane.bg}${lane.fg}${lane.text}${RESET}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function paintLayout(layout: PaintedLayout, separator: string): string {
|
|
25
|
+
const out: string[] = [];
|
|
26
|
+
for (let i = 0; i < layout.primary.length; i++) {
|
|
27
|
+
if (i > 0) out.push(separator);
|
|
28
|
+
out.push(paintLane(layout.primary[i]!));
|
|
29
|
+
}
|
|
30
|
+
return out.join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function paintSecondary(layout: PaintedLayout, separator: string): string {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (let i = 0; i < layout.secondary.length; i++) {
|
|
36
|
+
if (i > 0) out.push(separator);
|
|
37
|
+
out.push(paintLane(layout.secondary[i]!));
|
|
38
|
+
}
|
|
39
|
+
return out.join("");
|
|
40
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// v2 render adapter: wires the pure layout + paint into a top/secondary
|
|
2
|
+
// string pair matching the shape that status-line-renderers.ts consumers
|
|
3
|
+
// (renderPowerlinePrimaryLines / renderPowerlineSecondaryLines) already
|
|
4
|
+
// expect. This is the U12 cutover entry point. v1 paths remain in place
|
|
5
|
+
// until a follow-up PR deletes them after live-golden verification.
|
|
6
|
+
//
|
|
7
|
+
// ponytail: this is a delegating adapter, not a replacement. The
|
|
8
|
+
// status-line-renderers.ts consumers stay on v1 until v2 is exercised
|
|
9
|
+
// against real presets; the adapter exists so consumers can opt in via a
|
|
10
|
+
// single import swap once that is done.
|
|
11
|
+
|
|
12
|
+
import { computeLaneLayout, type LayoutSegment, type WidthClass } from "./layout.ts";
|
|
13
|
+
import { paintLayout, paintSecondary, type PaintedLane } from "./paint.ts";
|
|
14
|
+
|
|
15
|
+
export interface V2Segment extends LayoutSegment {}
|
|
16
|
+
|
|
17
|
+
export interface V2LaneConfig {
|
|
18
|
+
primary: string[];
|
|
19
|
+
secondary: string[];
|
|
20
|
+
/** Separator between painted lanes; styled separators carry ANSI. */
|
|
21
|
+
separator?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface V2RenderResult {
|
|
25
|
+
topContent: string;
|
|
26
|
+
secondaryContent: string;
|
|
27
|
+
dropped: LayoutSegment[];
|
|
28
|
+
widthClass: WidthClass;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SEPARATOR = " | ";
|
|
32
|
+
|
|
33
|
+
function toPainted(seg: LayoutSegment): PaintedLane {
|
|
34
|
+
return {
|
|
35
|
+
id: seg.id,
|
|
36
|
+
text: seg.text,
|
|
37
|
+
bg: "",
|
|
38
|
+
fg: "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderPowerlineV2(
|
|
43
|
+
segments: V2Segment[],
|
|
44
|
+
width: number,
|
|
45
|
+
config: V2LaneConfig,
|
|
46
|
+
): V2RenderResult {
|
|
47
|
+
const separator = config.separator ?? SEPARATOR;
|
|
48
|
+
const layout = computeLaneLayout(segments, width, {
|
|
49
|
+
primary: config.primary,
|
|
50
|
+
secondary: config.secondary,
|
|
51
|
+
separator,
|
|
52
|
+
maxWidth: width,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const painted = {
|
|
56
|
+
primary: layout.primary.map(toPainted),
|
|
57
|
+
secondary: layout.secondary.map(toPainted),
|
|
58
|
+
dropped: [] as PaintedLane[],
|
|
59
|
+
widthClass: layout.widthClass,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
topContent: paintLayout(painted, separator),
|
|
64
|
+
secondaryContent: paintSecondary(painted, separator),
|
|
65
|
+
dropped: layout.dropped,
|
|
66
|
+
widthClass: layout.widthClass,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 status-line entry (U12 cutover). Composes the single render path:
|
|
3
|
+
* segments render through the per-segment fault-isolated `renderSegment`
|
|
4
|
+
* boundary, the motion rail renders as a first-class layout segment, and
|
|
5
|
+
* lanes flow through `computeLaneLayout` + `paintLayout`. The legacy v1
|
|
6
|
+
* three-lane renderer (src/signal/render.ts) is deleted; rail semantics
|
|
7
|
+
* live on in ./motion-rail.ts.
|
|
8
|
+
*
|
|
9
|
+
* Priority scheme preserves the v1 reading order left -> rail -> right:
|
|
10
|
+
* left segments 10_000-i, rail 5_000, right segments 1_000-i. Under width
|
|
11
|
+
* pressure computeLaneLayout drops the lowest priorities first, so the
|
|
12
|
+
* right lane yields before the rail, and the rail before the left —
|
|
13
|
+
* matching v1's "preserve model/git first, then context/queue" squeeze.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
PresetDef,
|
|
18
|
+
SegmentContext,
|
|
19
|
+
SignalSpec,
|
|
20
|
+
StatusLineLayout,
|
|
21
|
+
StatusLineSegmentId,
|
|
22
|
+
StatusLineSeparatorStyle,
|
|
23
|
+
} from "../config/types.ts";
|
|
24
|
+
import { mergeSegmentsWithCustomItems } from "../config/powerline-config.ts";
|
|
25
|
+
import { renderSegment } from "../segments/index.ts";
|
|
26
|
+
import { getContributedSignalSources } from "../extension/contrib/registry.ts";
|
|
27
|
+
import { getSeparator } from "../theme/separators.ts";
|
|
28
|
+
import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
|
|
29
|
+
import { renderActivity } from "./motion-rail.ts";
|
|
30
|
+
import { renderPowerlineV2, type V2Segment } from "./v2-adapter.ts";
|
|
31
|
+
import type { SignalRuntime } from "../signal/controller.ts";
|
|
32
|
+
|
|
33
|
+
export interface StatusLineV2Options {
|
|
34
|
+
separatorStyle: StatusLineSeparatorStyle;
|
|
35
|
+
signal: SignalSpec;
|
|
36
|
+
ascii?: boolean;
|
|
37
|
+
layout?: StatusLineLayout | null;
|
|
38
|
+
disabledSegments?: StatusLineSegmentId[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ANSI_CONTROL_RE =
|
|
42
|
+
/\x1B(?:\][^\x07]*(?:\u0007|\x1B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])|\x9B[0-?]*[ -/]*[@-~]|\x9D[^\x07]*(?:\u0007|\x1B\\)/g;
|
|
43
|
+
|
|
44
|
+
function contributedTexts(ctx: SegmentContext): string[] {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for (const src of getContributedSignalSources()) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = src.render?.(ctx);
|
|
49
|
+
if (!raw || typeof raw !== "string") continue;
|
|
50
|
+
const content = raw.trim();
|
|
51
|
+
if (!content.replace(ANSI_CONTROL_RE, "").trim()) continue;
|
|
52
|
+
// ponytail: fault-isolated — a throwing or empty source never takes the line down
|
|
53
|
+
out.push(content);
|
|
54
|
+
} catch {
|
|
55
|
+
// skip failing contribution
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function styledSeparator(separator: string): string {
|
|
62
|
+
const color = getFgAnsiCode("sep");
|
|
63
|
+
const reset = colorEnabled() ? ansi.reset : "";
|
|
64
|
+
return ` ${color}${separator}${reset} `;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderStatusLineV2(
|
|
68
|
+
ctx: SegmentContext,
|
|
69
|
+
preset: PresetDef,
|
|
70
|
+
runtime: SignalRuntime,
|
|
71
|
+
width: number,
|
|
72
|
+
options: StatusLineV2Options,
|
|
73
|
+
): { topContent: string; secondaryContent: string } {
|
|
74
|
+
const merged = mergeSegmentsWithCustomItems(
|
|
75
|
+
preset,
|
|
76
|
+
ctx.effectiveCustomItems,
|
|
77
|
+
{
|
|
78
|
+
layout: options.layout ?? null,
|
|
79
|
+
disabledSegments: options.disabledSegments ?? [],
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const segments: V2Segment[] = [];
|
|
84
|
+
const primary: string[] = [];
|
|
85
|
+
|
|
86
|
+
const pushSegment = (id: StatusLineSegmentId, priority: number): void => {
|
|
87
|
+
try {
|
|
88
|
+
const rendered = renderSegment(id, ctx);
|
|
89
|
+
if (!rendered.visible || !rendered.content) return;
|
|
90
|
+
segments.push({ id, text: rendered.content, priority });
|
|
91
|
+
primary.push(id);
|
|
92
|
+
} catch {
|
|
93
|
+
// renderSegment is the established per-segment error boundary; a
|
|
94
|
+
// throwing segment never blanks the line.
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
merged.leftSegments.forEach((id, i) => pushSegment(id, 10_000 - i));
|
|
99
|
+
|
|
100
|
+
const rail = renderActivity(runtime, options.signal, options.ascii);
|
|
101
|
+
segments.push({ id: "signal", text: rail, priority: 5_000 });
|
|
102
|
+
primary.push("signal");
|
|
103
|
+
|
|
104
|
+
merged.rightSegments.forEach((id, i) => pushSegment(id, 1_000 - i));
|
|
105
|
+
contributedTexts(ctx).forEach((text, i) => {
|
|
106
|
+
const id = `contrib:${i}`;
|
|
107
|
+
segments.push({ id, text, priority: 900 - i });
|
|
108
|
+
primary.push(id);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
merged.secondarySegments.forEach((id, i) => {
|
|
112
|
+
try {
|
|
113
|
+
const rendered = renderSegment(id, ctx);
|
|
114
|
+
if (!rendered.visible || !rendered.content) return;
|
|
115
|
+
segments.push({ id, text: rendered.content, priority: 500 - i });
|
|
116
|
+
} catch {
|
|
117
|
+
// Keep secondary segments behind the same fault boundary as primary.
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const separatorGlyph = getSeparator(options.separatorStyle).left;
|
|
122
|
+
const result = renderPowerlineV2(segments, width, {
|
|
123
|
+
primary,
|
|
124
|
+
secondary: [...merged.secondarySegments],
|
|
125
|
+
separator: styledSeparator(separatorGlyph),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
topContent: result.topContent,
|
|
130
|
+
secondaryContent: result.secondaryContent,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment pipeline v2 (U2, KTD5).
|
|
3
|
+
*
|
|
4
|
+
* Routes builtin segments and contributed signal sources through the same
|
|
5
|
+
* try/catch + cache + render-budget path. v1's `renderSegment` /
|
|
6
|
+
* `renderContributedSources` still work; U11 cuts them over to call this
|
|
7
|
+
* pipeline instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RenderedSegment, SegmentContext } from "../config/types.ts";
|
|
11
|
+
import type {
|
|
12
|
+
PipelineContext,
|
|
13
|
+
PipelineOptions,
|
|
14
|
+
PipelineSegment,
|
|
15
|
+
PipelineSource,
|
|
16
|
+
RenderedPipelineOutput,
|
|
17
|
+
} from "./types.ts";
|
|
18
|
+
|
|
19
|
+
interface CacheEntry {
|
|
20
|
+
key: string;
|
|
21
|
+
value: RenderedPipelineOutput;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const cacheStore = new Map<string, CacheEntry>();
|
|
25
|
+
|
|
26
|
+
function cacheKey(kind: "builtin" | "source", id: string, version: string): string {
|
|
27
|
+
// Namespace by producer type: a builtin segment and a contributed source
|
|
28
|
+
// may share an id and must never reuse each other's cached entry.
|
|
29
|
+
return `${kind}:${id}:${version}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeSourceOutput(raw: string | undefined): RenderedSegment {
|
|
33
|
+
if (raw === undefined) return { content: "", visible: false };
|
|
34
|
+
const trimmed = raw.trim();
|
|
35
|
+
if (!trimmed) return { content: "", visible: false };
|
|
36
|
+
return { content: raw, visible: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function renderBuiltin(
|
|
40
|
+
segment: PipelineSegment,
|
|
41
|
+
ctx: SegmentContext,
|
|
42
|
+
budgetMs: number,
|
|
43
|
+
): Promise<RenderedSegment | null> {
|
|
44
|
+
if (budgetMs > 0) {
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
try {
|
|
47
|
+
return await Promise.race([
|
|
48
|
+
(async () => segment.render(ctx))(),
|
|
49
|
+
new Promise<RenderedSegment>((_resolve, reject) => {
|
|
50
|
+
timer = setTimeout(
|
|
51
|
+
() => reject(new Error("render-budget-exceeded")),
|
|
52
|
+
budgetMs,
|
|
53
|
+
);
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof Error && err.message === "render-budget-exceeded") {
|
|
58
|
+
return { content: "", visible: false };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
} finally {
|
|
62
|
+
if (timer) clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return await (async () => segment.render(ctx))();
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function renderSource(
|
|
73
|
+
source: PipelineSource,
|
|
74
|
+
ctx: SegmentContext,
|
|
75
|
+
budgetMs: number,
|
|
76
|
+
): Promise<RenderedSegment | null> {
|
|
77
|
+
if (budgetMs > 0) {
|
|
78
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
try {
|
|
80
|
+
const raw = await Promise.race([
|
|
81
|
+
(async () => source.render(ctx))(),
|
|
82
|
+
new Promise<string | undefined>((_resolve, reject) => {
|
|
83
|
+
timer = setTimeout(
|
|
84
|
+
() => reject(new Error("render-budget-exceeded")),
|
|
85
|
+
budgetMs,
|
|
86
|
+
);
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
return normalizeSourceOutput(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
} finally {
|
|
93
|
+
if (timer) clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let raw: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
raw = await (async () => source.render(ctx))();
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return normalizeSourceOutput(raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function runSegmentPipeline(
|
|
106
|
+
segments: readonly PipelineSegment[],
|
|
107
|
+
sources: readonly PipelineSource[],
|
|
108
|
+
ctx: PipelineContext,
|
|
109
|
+
options: PipelineOptions = {},
|
|
110
|
+
): Promise<RenderedPipelineOutput[]> {
|
|
111
|
+
const { cache = false, budgetMs = 0 } = options;
|
|
112
|
+
// PipelineContext extends SegmentContext, so renderers receive the context
|
|
113
|
+
// values they read (usage stats, window, thresholds) without a cast.
|
|
114
|
+
const segCtx: SegmentContext = ctx;
|
|
115
|
+
const out: RenderedPipelineOutput[] = [];
|
|
116
|
+
|
|
117
|
+
for (const segment of segments) {
|
|
118
|
+
const key = cacheKey("builtin", segment.id, ctx.version);
|
|
119
|
+
if (cache) {
|
|
120
|
+
const hit = cacheStore.get(key);
|
|
121
|
+
if (hit) {
|
|
122
|
+
out.push({ ...hit.value, cached: true });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const rendered = await renderBuiltin(segment, segCtx, budgetMs);
|
|
127
|
+
if (rendered === null) continue;
|
|
128
|
+
const entry: RenderedPipelineOutput = {
|
|
129
|
+
id: segment.id,
|
|
130
|
+
content: rendered.content,
|
|
131
|
+
visible: rendered.visible,
|
|
132
|
+
cached: false,
|
|
133
|
+
};
|
|
134
|
+
out.push(entry);
|
|
135
|
+
if (cache) cacheStore.set(key, { key, value: entry });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const source of sources) {
|
|
139
|
+
const key = cacheKey("source", source.id, ctx.version);
|
|
140
|
+
if (cache) {
|
|
141
|
+
const hit = cacheStore.get(key);
|
|
142
|
+
if (hit) {
|
|
143
|
+
out.push({ ...hit.value, cached: true });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const rendered = await renderSource(source, segCtx, budgetMs);
|
|
148
|
+
if (rendered === null || !rendered.visible) continue;
|
|
149
|
+
const entry: RenderedPipelineOutput = {
|
|
150
|
+
id: source.id,
|
|
151
|
+
content: rendered.content,
|
|
152
|
+
visible: rendered.visible,
|
|
153
|
+
cached: false,
|
|
154
|
+
};
|
|
155
|
+
out.push(entry);
|
|
156
|
+
if (cache) cacheStore.set(key, { key, value: entry });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function clearSegmentCache(): void {
|
|
163
|
+
cacheStore.clear();
|
|
164
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Segment pipeline v2 (U2). Pure types — no TUI imports. */
|
|
2
|
+
|
|
3
|
+
import type { RenderedSegment, SegmentContext } from "../config/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface PipelineContext extends SegmentContext {
|
|
6
|
+
/** Bumped by callers when any input that affects rendering changes; the
|
|
7
|
+
* pipeline uses this together with the segment id as the cache key. */
|
|
8
|
+
version: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A builtin-style segment: typed contract with id + render. */
|
|
12
|
+
export interface PipelineSegment {
|
|
13
|
+
id: string;
|
|
14
|
+
render(ctx: SegmentContext): RenderedSegment | Promise<RenderedSegment>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A contributed source: free-form id, render returns a raw string. */
|
|
18
|
+
export interface PipelineSource {
|
|
19
|
+
id: string;
|
|
20
|
+
render(ctx: SegmentContext): string | undefined | Promise<string | undefined>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RenderedPipelineOutput {
|
|
24
|
+
id: string;
|
|
25
|
+
content: string;
|
|
26
|
+
visible: boolean;
|
|
27
|
+
cached: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PipelineOptions {
|
|
31
|
+
/** Cache rendered results by (id, ctx.version). Default false. */
|
|
32
|
+
cache?: boolean;
|
|
33
|
+
/** Per-segment render budget in ms. Exceeding segments degrade to hidden.
|
|
34
|
+
* Set to 0 to disable. Default 0 (no budget). */
|
|
35
|
+
budgetMs?: number;
|
|
36
|
+
}
|
package/src/segments/usage.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { StatusLineSegment } from "../config/types.ts";
|
|
|
2
2
|
import { getIcons } from "../theme/icons.ts";
|
|
3
3
|
import { formatUsdCost } from "../usage/rates.ts";
|
|
4
4
|
import { costColorForBudget, tokenBudgetLevel } from "../usage/token-budget.ts";
|
|
5
|
+
import { ansi, colorEnabled } from "../theme/colors.ts";
|
|
5
6
|
import { color, withIcon, formatTokens } from "./shared.ts";
|
|
6
7
|
|
|
7
8
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -110,18 +111,56 @@ export const contextPctSegment: StatusLineSegment = {
|
|
|
110
111
|
percentOnly
|
|
111
112
|
? color(ctx, semantic, text)
|
|
112
113
|
: withIcon(icons.context, color(ctx, semantic, text));
|
|
114
|
+
let semantic: "context" | "contextWarn" | "contextError";
|
|
113
115
|
if (contextPercent > 90) {
|
|
114
|
-
|
|
116
|
+
semantic = "contextError";
|
|
115
117
|
} else if (contextPercent > 70) {
|
|
116
|
-
|
|
118
|
+
semantic = "contextWarn";
|
|
117
119
|
} else {
|
|
118
|
-
|
|
120
|
+
semantic = "context";
|
|
119
121
|
}
|
|
122
|
+
const baseContent = colored(semantic);
|
|
123
|
+
|
|
124
|
+
// Append a compact 8-cell fill bar so usage is scannable at a glance.
|
|
125
|
+
// Filled cells use the semantic color, empty cells use the muted separator.
|
|
126
|
+
const bar = contextFillBar(contextPercent, (s) => color(ctx, s, ""));
|
|
127
|
+
content = percentOnly ? baseContent : `${baseContent} ${bar}`;
|
|
120
128
|
|
|
121
129
|
return { content, visible: true };
|
|
122
130
|
},
|
|
123
131
|
};
|
|
124
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Compact 8-cell context fill bar. Filled cells track the usage percentage;
|
|
135
|
+
* the cell color follows the threshold (normal/warn/error) so the bar doubles
|
|
136
|
+
* as a glanceable usage meter alongside the numeric readout.
|
|
137
|
+
*/
|
|
138
|
+
function contextFillBar(
|
|
139
|
+
percent: number,
|
|
140
|
+
colorFn: (semantic: "context" | "contextWarn" | "contextError") => string,
|
|
141
|
+
): string {
|
|
142
|
+
const CELLS = 8;
|
|
143
|
+
// Clamp to the bar's cell count: percentages above 100 must not grow the
|
|
144
|
+
// status line, and negative percentages must not underflow the empty run.
|
|
145
|
+
const filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));
|
|
146
|
+
let semantic: "context" | "contextWarn" | "contextError";
|
|
147
|
+
if (percent > 90) {
|
|
148
|
+
semantic = "contextError";
|
|
149
|
+
} else if (percent > 70) {
|
|
150
|
+
semantic = "contextWarn";
|
|
151
|
+
} else {
|
|
152
|
+
semantic = "context";
|
|
153
|
+
}
|
|
154
|
+
// Filled cells are left-aligned, so we can emit them as one colored run,
|
|
155
|
+
// then the empty cells as another — just two color codes total, not one per cell.
|
|
156
|
+
const filledColor = colorFn(semantic);
|
|
157
|
+
const emptyColor = colorFn("context");
|
|
158
|
+
const reset = colorEnabled() ? ansi.reset : "";
|
|
159
|
+
const filledRun = filled > 0 ? `${filledColor}${"▓".repeat(filled)}${reset}` : "";
|
|
160
|
+
const emptyRun = filled < CELLS ? `${emptyColor}${"░".repeat(CELLS - filled)}${reset}` : "";
|
|
161
|
+
return `${filledRun}${emptyRun}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
125
164
|
export const contextTotalSegment: StatusLineSegment = {
|
|
126
165
|
id: "context_total",
|
|
127
166
|
render(ctx) {
|