@groeponline/pi-wishcraft 0.20.0 → 0.22.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 +17 -0
- package/README.md +83 -0
- package/ROADMAP.md +18 -20
- package/bash-mode/editor-ghost.ts +63 -0
- package/bash-mode/editor-history.ts +81 -0
- package/bash-mode/editor-input.ts +35 -0
- package/bash-mode/editor.ts +16 -134
- package/bash-mode/shell-session.ts +12 -15
- package/bash-mode/types.ts +2 -0
- package/docs/bash-mode.md +46 -0
- package/docs/commands.md +122 -0
- package/docs/configuration.md +307 -0
- package/docs/index.md +18 -0
- package/docs/segments.md +168 -0
- package/docs/skill-manager.md +7 -0
- package/docs/stash-and-shortcuts.md +64 -0
- package/docs/working-vibes.md +71 -0
- package/package.json +2 -1
- package/queue/store.ts +72 -3
- package/src/config/custom-items.ts +45 -0
- package/src/config/parse.ts +19 -3
- package/src/config/powerline-config.ts +5 -0
- package/src/config/primitives.ts +13 -1
- package/src/config/segment-options.ts +29 -24
- package/src/config/settings-patch.ts +23 -2
- package/src/config/types.ts +32 -17
- package/src/extension/commands/bash-mode-actions.ts +1 -0
- package/src/extension/commands/commands.ts +36 -18
- package/src/extension/commands/powerline-completions.ts +8 -0
- package/src/extension/commands/powerline-doctor.ts +247 -0
- package/src/extension/commands/powerline-export.ts +94 -0
- package/src/extension/commands/queue-commands.ts +29 -1
- package/src/extension/commands/vibe-command.ts +49 -1
- package/src/extension/core/constants.ts +1 -0
- package/src/extension/core/segment-context.ts +38 -2
- package/src/extension/core/state.ts +5 -1
- package/src/extension/core/status-export.ts +68 -0
- package/src/extension/core/types.ts +4 -0
- package/src/extension/hooks/repairs.ts +89 -16
- package/src/extension/session/activate.ts +1 -2
- package/src/extension/session/cost-alert.ts +36 -0
- package/src/extension/session/session-lifecycle.ts +82 -1
- package/src/extension/settings/settings-io.ts +23 -17
- package/src/extension/settings/wishcraft-config.ts +2 -1
- package/src/extension/shortcuts/shortcuts-config.ts +5 -0
- package/src/extension/ui/layout.ts +1 -1
- package/src/extension/ui/menu-items.ts +379 -0
- package/src/extension/ui/menu-views.ts +377 -191
- package/src/extension/ui/overlay-chrome.ts +163 -0
- package/src/extension/ui/powerline-menu-view.ts +2 -4
- package/src/extension/ui/token-overlays.ts +82 -0
- package/src/extension/welcome/welcome-integration.ts +5 -9
- package/src/segments/registry.ts +100 -39
- package/src/segments/system.ts +204 -64
- package/src/segments/usage.ts +10 -4
- package/src/usage/ledger.ts +6 -0
- package/src/usage/token-budget.ts +57 -0
- package/src/usage/tps-ring.ts +151 -0
- package/src/usage/usage-store.ts +351 -0
- package/src/welcome/banner.ts +2 -1
- package/src/welcome/index.ts +3 -1
- package/src/welcome/overlay.ts +2 -0
- package/src/welcome/renderer.ts +52 -10
- package/src/welcome/types.ts +1 -2
- package/src/welcome/whats-new.ts +156 -0
- package/src/welcome/widgets/index.ts +3 -0
- package/src/welcome/widgets/whats-new-widget.ts +19 -0
- package/src/working-vibes/generate.ts +144 -58
- package/src/working-vibes/index.ts +10 -2
- package/src/working-vibes/storage.ts +5 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared overlay chrome: rounded box, accent title, dim hint, and a
|
|
3
|
+
* substring filter over SelectList items (label + value + description).
|
|
4
|
+
* Pi's SelectList.setFilter is prefix-only on `value`; overlays must not
|
|
5
|
+
* inherit that.
|
|
6
|
+
*/
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
matchesKey,
|
|
10
|
+
type SelectItem,
|
|
11
|
+
SelectList,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
const NONE_VALUE = "__none__";
|
|
16
|
+
|
|
17
|
+
export function overlaySelectListTheme(theme: Theme) {
|
|
18
|
+
return {
|
|
19
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
20
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
21
|
+
description: (text: string) => theme.fg("muted", text),
|
|
22
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
23
|
+
noMatch: (text: string) => theme.fg("warning", text),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Case-insensitive substring match on value, label, and description. */
|
|
28
|
+
export function applyOverlayFilter(
|
|
29
|
+
items: readonly SelectItem[],
|
|
30
|
+
query: string,
|
|
31
|
+
): SelectItem[] {
|
|
32
|
+
const q = query.trim().toLowerCase();
|
|
33
|
+
if (!q) return [...items];
|
|
34
|
+
return items.filter((item) => {
|
|
35
|
+
const hay = `${item.value}\n${item.label}\n${item.description ?? ""}`;
|
|
36
|
+
return hay.toLowerCase().includes(q);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isOverlayPrintable(data: string): boolean {
|
|
41
|
+
return data.length === 1 && data >= " " && data <= "~";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function applyOverlayQueryKey(query: string, data: string): string | null {
|
|
45
|
+
if (data === "\x15") return "";
|
|
46
|
+
if (data === "\x7f" || data === "\b" || matchesKey(data, "backspace")) {
|
|
47
|
+
return query.slice(0, -1);
|
|
48
|
+
}
|
|
49
|
+
if (isOverlayPrintable(data)) return query + data;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderOverlayBox(
|
|
54
|
+
theme: Theme,
|
|
55
|
+
width: number,
|
|
56
|
+
title: string,
|
|
57
|
+
body: readonly string[],
|
|
58
|
+
hint: string,
|
|
59
|
+
): string[] {
|
|
60
|
+
const innerWidth = Math.max(1, width - 2);
|
|
61
|
+
const border = (text: string) => theme.fg("dim", text);
|
|
62
|
+
const wrapRow = (text: string): string =>
|
|
63
|
+
`${border("│")}${truncateToWidth(text, innerWidth, "…", true)}${border("│")}`;
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
|
|
66
|
+
lines.push(wrapRow(theme.fg("accent", theme.bold(title))));
|
|
67
|
+
lines.push(border(`├${"─".repeat(innerWidth)}┤`));
|
|
68
|
+
for (const line of body) lines.push(wrapRow(line));
|
|
69
|
+
lines.push(border(`├${"─".repeat(innerWidth)}┤`));
|
|
70
|
+
lines.push(wrapRow(theme.fg("dim", hint)));
|
|
71
|
+
lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listForQuery(
|
|
76
|
+
items: readonly SelectItem[],
|
|
77
|
+
query: string,
|
|
78
|
+
maxVisible: number,
|
|
79
|
+
theme: Theme,
|
|
80
|
+
): SelectList {
|
|
81
|
+
const filtered = applyOverlayFilter(items, query);
|
|
82
|
+
const visible =
|
|
83
|
+
filtered.length > 0
|
|
84
|
+
? filtered
|
|
85
|
+
: [
|
|
86
|
+
{
|
|
87
|
+
label: `geen match voor '${query}'`,
|
|
88
|
+
value: NONE_VALUE,
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
const list = new SelectList(
|
|
92
|
+
visible,
|
|
93
|
+
Math.max(1, Math.min(maxVisible, visible.length)),
|
|
94
|
+
overlaySelectListTheme(theme),
|
|
95
|
+
);
|
|
96
|
+
return list;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function showSelectOverlay(
|
|
100
|
+
ctx: any,
|
|
101
|
+
title: string,
|
|
102
|
+
hint: string,
|
|
103
|
+
items: SelectItem[],
|
|
104
|
+
maxVisible: number,
|
|
105
|
+
): Promise<SelectItem | null> {
|
|
106
|
+
return ctx.ui.custom(
|
|
107
|
+
(
|
|
108
|
+
tui: any,
|
|
109
|
+
theme: Theme,
|
|
110
|
+
_keybindings: any,
|
|
111
|
+
done: (result: SelectItem | null) => void,
|
|
112
|
+
) => {
|
|
113
|
+
let query = "";
|
|
114
|
+
let selectList = listForQuery(items, query, maxVisible, theme);
|
|
115
|
+
const bind = () => {
|
|
116
|
+
selectList.onSelect = (item) => {
|
|
117
|
+
if (item.value === NONE_VALUE) return;
|
|
118
|
+
done(item);
|
|
119
|
+
};
|
|
120
|
+
selectList.onCancel = () => done(null);
|
|
121
|
+
};
|
|
122
|
+
bind();
|
|
123
|
+
|
|
124
|
+
const filterHint = () => {
|
|
125
|
+
const extra = query
|
|
126
|
+
? `filter '${query}' · ctrl+u clear`
|
|
127
|
+
: "type to filter";
|
|
128
|
+
return `${hint} · ${extra}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
render: (width: number) =>
|
|
133
|
+
renderOverlayBox(
|
|
134
|
+
theme,
|
|
135
|
+
width,
|
|
136
|
+
title,
|
|
137
|
+
selectList.render(Math.max(1, width - 2)),
|
|
138
|
+
filterHint(),
|
|
139
|
+
),
|
|
140
|
+
invalidate: () => selectList.invalidate(),
|
|
141
|
+
handleInput: (data: string) => {
|
|
142
|
+
const next = applyOverlayQueryKey(query, data);
|
|
143
|
+
if (next !== null) {
|
|
144
|
+
query = next;
|
|
145
|
+
selectList = listForQuery(items, query, maxVisible, theme);
|
|
146
|
+
bind();
|
|
147
|
+
tui.requestRender();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
selectList.handleInput(data);
|
|
151
|
+
tui.requestRender();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
overlay: true,
|
|
157
|
+
overlayOptions: () => ({
|
|
158
|
+
verticalAlign: "center",
|
|
159
|
+
horizontalAlign: "center",
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
showSegmentNavigator,
|
|
7
7
|
showSelectOverlay,
|
|
8
8
|
} from "./menu-views.ts";
|
|
9
|
+
import { showTpsOverlay } from "./token-overlays.ts";
|
|
9
10
|
import {
|
|
10
11
|
assertPowerlineMenuBounds,
|
|
11
12
|
type PowerlineMenuNode,
|
|
@@ -47,10 +48,7 @@ async function activatePowerlineMenuAction(
|
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
49
50
|
if (id === "tps") {
|
|
50
|
-
|
|
51
|
-
? `(override ${process.env.POWERLINE_TPS})`
|
|
52
|
-
: "(live 1s window)";
|
|
53
|
-
ctx.ui.notify(`TPS ${live}`, "info");
|
|
51
|
+
await showTpsOverlay(rt, ctx);
|
|
54
52
|
return;
|
|
55
53
|
}
|
|
56
54
|
if (id === "toggle") {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { config } from "../core/state.ts";
|
|
4
|
+
import type { RuntimeState } from "../core/types.ts";
|
|
5
|
+
import {
|
|
6
|
+
summarizeTpsRing,
|
|
7
|
+
tpsOverlayLines,
|
|
8
|
+
tpsSamples,
|
|
9
|
+
} from "../../usage/tps-ring.ts";
|
|
10
|
+
import {
|
|
11
|
+
formatUsageOverlayLines,
|
|
12
|
+
loadUsageFileFromDisk,
|
|
13
|
+
summarizeUsageOverlay,
|
|
14
|
+
} from "../../usage/usage-store.ts";
|
|
15
|
+
import { parseTokenBudget } from "../../usage/token-budget.ts";
|
|
16
|
+
import { getSessionTotalCost } from "../../usage/ledger.ts";
|
|
17
|
+
import { readSettings } from "../settings/settings-io.ts";
|
|
18
|
+
import { showSelectOverlay } from "./overlay-chrome.ts";
|
|
19
|
+
|
|
20
|
+
function sessionTotals(rt: RuntimeState, ctx: any) {
|
|
21
|
+
const events = rt.sessionBranchCache.get(ctx.sessionManager);
|
|
22
|
+
const snap = rt.tokenStatsCache.get(events);
|
|
23
|
+
return {
|
|
24
|
+
input: snap.input,
|
|
25
|
+
output: snap.output,
|
|
26
|
+
cacheRead: snap.cacheRead,
|
|
27
|
+
cacheWrite: snap.cacheWrite,
|
|
28
|
+
cost: getSessionTotalCost(snap),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildTpsOverlayItems(rt: RuntimeState, ctx: any): SelectItem[] {
|
|
33
|
+
const events = rt.sessionBranchCache.get(ctx.sessionManager);
|
|
34
|
+
const snap = rt.tokenStatsCache.get(events);
|
|
35
|
+
const summary = summarizeTpsRing(
|
|
36
|
+
tpsSamples,
|
|
37
|
+
Date.now(),
|
|
38
|
+
{ input: snap.input, output: snap.output },
|
|
39
|
+
{
|
|
40
|
+
windowMs: config.segmentOptions?.tps?.windowMs ?? 1000,
|
|
41
|
+
override: process.env.POWERLINE_TPS,
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
const lines = tpsOverlayLines(summary);
|
|
45
|
+
return lines.map((line) => ({ label: line, value: line }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildUsageOverlayItems(rt: RuntimeState, ctx: any): SelectItem[] {
|
|
49
|
+
const wishcraft = readSettings(ctx.cwd ?? process.cwd()).wishcraft;
|
|
50
|
+
const budget = parseTokenBudget(wishcraft);
|
|
51
|
+
const summary = summarizeUsageOverlay({
|
|
52
|
+
file: loadUsageFileFromDisk(),
|
|
53
|
+
session: sessionTotals(rt, ctx),
|
|
54
|
+
dailyLimit: budget.daily,
|
|
55
|
+
});
|
|
56
|
+
const lines = formatUsageOverlayLines(summary);
|
|
57
|
+
return lines.map((line) => ({ label: line, value: line }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function showTpsOverlay(rt: RuntimeState, ctx: any): Promise<void> {
|
|
61
|
+
const items = buildTpsOverlayItems(rt, ctx);
|
|
62
|
+
const picked = await showSelectOverlay(
|
|
63
|
+
ctx,
|
|
64
|
+
"TPS",
|
|
65
|
+
"same 1s window as the segment · enter copy · esc close",
|
|
66
|
+
items,
|
|
67
|
+
Math.min(items.length, 12),
|
|
68
|
+
);
|
|
69
|
+
if (picked) ctx.ui.notify(picked.value, "info");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function showUsageOverlay(rt: RuntimeState, ctx: any): Promise<void> {
|
|
73
|
+
const items = buildUsageOverlayItems(rt, ctx);
|
|
74
|
+
const picked = await showSelectOverlay(
|
|
75
|
+
ctx,
|
|
76
|
+
"Usage",
|
|
77
|
+
"today / week from wishcraft-usage.json · enter copy · esc close",
|
|
78
|
+
items,
|
|
79
|
+
Math.min(items.length, 16),
|
|
80
|
+
);
|
|
81
|
+
if (picked) ctx.ui.notify(picked.value, "info");
|
|
82
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
WelcomeComponent,
|
|
3
3
|
WelcomeHeader,
|
|
4
4
|
discoverLoadedCounts,
|
|
5
|
+
discoverWhatsNew,
|
|
5
6
|
getRecentSessions,
|
|
6
7
|
} from "../../welcome/index.ts";
|
|
7
8
|
import { estimateInitialContextTokens } from "../../usage/context.ts";
|
|
@@ -19,6 +20,7 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
|
|
|
19
20
|
const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
|
|
20
21
|
const hasStash =
|
|
21
22
|
rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
|
|
23
|
+
const whatsNew = discoverWhatsNew();
|
|
22
24
|
|
|
23
25
|
const header = new WelcomeHeader(
|
|
24
26
|
modelName,
|
|
@@ -28,6 +30,7 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
|
|
|
28
30
|
initialContextTokens,
|
|
29
31
|
queueCount,
|
|
30
32
|
hasStash,
|
|
33
|
+
whatsNew,
|
|
31
34
|
);
|
|
32
35
|
rt.welcomeHeaderActive = true;
|
|
33
36
|
|
|
@@ -83,6 +86,7 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
|
|
|
83
86
|
const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
|
|
84
87
|
const hasStash =
|
|
85
88
|
rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
|
|
89
|
+
const whatsNew = discoverWhatsNew();
|
|
86
90
|
|
|
87
91
|
ctx.ui
|
|
88
92
|
.custom(
|
|
@@ -100,18 +104,17 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
|
|
|
100
104
|
initialContextTokens,
|
|
101
105
|
queueCount,
|
|
102
106
|
hasStash,
|
|
107
|
+
whatsNew,
|
|
103
108
|
);
|
|
104
109
|
|
|
105
110
|
let countdown = 30;
|
|
106
111
|
let dismissed = false;
|
|
107
112
|
let interval: ReturnType<typeof setInterval> | null = null;
|
|
108
|
-
let glow: ReturnType<typeof setInterval> | null = null;
|
|
109
113
|
|
|
110
114
|
const dismiss = () => {
|
|
111
115
|
if (dismissed) return;
|
|
112
116
|
dismissed = true;
|
|
113
117
|
if (interval) clearInterval(interval);
|
|
114
|
-
if (glow) clearInterval(glow);
|
|
115
118
|
rt.dismissWelcomeOverlay = null;
|
|
116
119
|
done();
|
|
117
120
|
};
|
|
@@ -124,12 +127,6 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
|
|
|
124
127
|
if (countdown <= 0) dismiss();
|
|
125
128
|
}, 1000);
|
|
126
129
|
|
|
127
|
-
// wensballon: zachte vlam-flikker, alleen tijdens de overlay
|
|
128
|
-
glow = setInterval(() => {
|
|
129
|
-
if (dismissed) return;
|
|
130
|
-
tui.requestRender();
|
|
131
|
-
}, 140);
|
|
132
|
-
|
|
133
130
|
rt.dismissWelcomeOverlay = dismiss;
|
|
134
131
|
|
|
135
132
|
if (rt.welcomeOverlayShouldDismiss) {
|
|
@@ -145,7 +142,6 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
|
|
|
145
142
|
dispose: () => {
|
|
146
143
|
dismissed = true;
|
|
147
144
|
if (interval) clearInterval(interval);
|
|
148
|
-
if (glow) clearInterval(glow);
|
|
149
145
|
},
|
|
150
146
|
};
|
|
151
147
|
},
|
package/src/segments/registry.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
StatusLineSegment,
|
|
6
6
|
StatusLineSegmentId,
|
|
7
7
|
} from "../config/types.ts";
|
|
8
|
+
import { getIcons } from "../theme/icons.ts";
|
|
8
9
|
import {
|
|
9
10
|
modelSegment,
|
|
10
11
|
shellModeSegment,
|
|
@@ -43,6 +44,97 @@ import {
|
|
|
43
44
|
// Segment Registry
|
|
44
45
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
45
46
|
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
// Segment decoration (labels + templates)
|
|
49
|
+
//
|
|
50
|
+
// `powerline.segmentLabels.<id>` inserts a label between the icon and the
|
|
51
|
+
// value for ANY segment; `segmentOptions.<id>.template` replaces the value
|
|
52
|
+
// text ("{value}" is substituted). Both are applied centrally here so every
|
|
53
|
+
// segment honors them uniformly (previously only tps/open_ports did).
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
|
|
57
|
+
|
|
58
|
+
function stripAnsi(text: string): string {
|
|
59
|
+
return text.replace(ANSI_ESCAPE_RE, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Index of the first visible (non-escape) character in a rendered string. */
|
|
63
|
+
function firstVisibleCharIndex(content: string): number {
|
|
64
|
+
for (let i = 0; i < content.length; i++) {
|
|
65
|
+
if (content[i] === "\x1b") {
|
|
66
|
+
const match = ANSI_ESCAPE_RE.exec(content.slice(i));
|
|
67
|
+
if (match) {
|
|
68
|
+
i += match[0].length - 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return i;
|
|
73
|
+
}
|
|
74
|
+
return content.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function leadingIconAndValue(
|
|
78
|
+
visible: string,
|
|
79
|
+
): { icon: string; value: string } {
|
|
80
|
+
const trimmed = visible.trimStart();
|
|
81
|
+
const spaceIndex = trimmed.indexOf(" ");
|
|
82
|
+
if (spaceIndex <= 0) return { icon: "", value: trimmed };
|
|
83
|
+
|
|
84
|
+
const candidate = trimmed.slice(0, spaceIndex);
|
|
85
|
+
const icons = getIcons();
|
|
86
|
+
const iconValues = new Set(
|
|
87
|
+
Object.values(icons).filter((icon) => icon.length > 0),
|
|
88
|
+
);
|
|
89
|
+
if (!iconValues.has(candidate)) return { icon: "", value: trimmed };
|
|
90
|
+
return { icon: candidate, value: trimmed.slice(spaceIndex + 1).trimStart() };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply `segmentLabels` (label between icon and value) and
|
|
95
|
+
* `segmentOptions.<id>.template` ({value} substitution) to a rendered
|
|
96
|
+
* segment. Label-only insertion preserves the original ANSI styling spans;
|
|
97
|
+
* templates rebuild the content with the segment's leading style.
|
|
98
|
+
*/
|
|
99
|
+
export function applySegmentDecoration(
|
|
100
|
+
id: StatusLineSegmentId,
|
|
101
|
+
ctx: SegmentContext,
|
|
102
|
+
rendered: RenderedSegment,
|
|
103
|
+
): RenderedSegment {
|
|
104
|
+
const label = ctx.segmentLabels?.get(id);
|
|
105
|
+
const optionsForId = (
|
|
106
|
+
ctx.options as Record<string, { template?: string } | undefined> | undefined
|
|
107
|
+
)?.[id];
|
|
108
|
+
const template = optionsForId?.template;
|
|
109
|
+
if (!label && !template) return rendered;
|
|
110
|
+
if (!rendered.visible || !rendered.content) return rendered;
|
|
111
|
+
|
|
112
|
+
const visible = stripAnsi(rendered.content);
|
|
113
|
+
const { icon, value } = leadingIconAndValue(visible);
|
|
114
|
+
const firstVisibleIndex = firstVisibleCharIndex(rendered.content);
|
|
115
|
+
const styledPrefix = rendered.content.slice(0, firstVisibleIndex);
|
|
116
|
+
|
|
117
|
+
if (!template) {
|
|
118
|
+
// Label-only: pure insertion right after the icon, so every inner ANSI
|
|
119
|
+
// span of the original content is preserved untouched.
|
|
120
|
+
let insertAt = firstVisibleIndex + icon.length;
|
|
121
|
+
if (rendered.content[insertAt] === " ") insertAt += 1;
|
|
122
|
+
return {
|
|
123
|
+
content:
|
|
124
|
+
rendered.content.slice(0, insertAt) +
|
|
125
|
+
`${label} ` +
|
|
126
|
+
rendered.content.slice(insertAt),
|
|
127
|
+
visible: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Template (with optional label): rebuild with the segment's leading style.
|
|
132
|
+
let newValue = template.replaceAll("{value}", value);
|
|
133
|
+
if (label) newValue = `${label} ${newValue}`;
|
|
134
|
+
const content = `${styledPrefix}${icon ? `${icon} ` : ""}${newValue}${rendered.content.endsWith("\x1b[0m") ? "\x1b[0m" : ""}`;
|
|
135
|
+
return { content, visible: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
46
138
|
export const SEGMENTS: Record<BuiltinStatusLineSegmentId, StatusLineSegment> = {
|
|
47
139
|
model: modelSegment,
|
|
48
140
|
shell_mode: shellModeSegment,
|
|
@@ -72,47 +164,16 @@ export function renderSegment(
|
|
|
72
164
|
id: StatusLineSegmentId,
|
|
73
165
|
ctx: SegmentContext,
|
|
74
166
|
): RenderedSegment {
|
|
167
|
+
let rendered: RenderedSegment;
|
|
75
168
|
if (isCustomSegmentId(id)) {
|
|
76
169
|
const customId = id.slice("custom:".length);
|
|
77
170
|
const computed = customComputedSegments.get(customId);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return { content: "", visible: false };
|
|
85
|
-
}
|
|
86
|
-
return applyLabelAndTemplate(id, segment.render(ctx), ctx);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
90
|
-
|
|
91
|
-
/** Centrale label + template-afhandeling voor álle segments.
|
|
92
|
-
* Template (segmentOptions.<seg>.template) wint op het label; `{value}`
|
|
93
|
-
* wordt vervangen door de kale (ANSI-vrije) segmenttekst. */
|
|
94
|
-
function applyLabelAndTemplate(
|
|
95
|
-
id: string,
|
|
96
|
-
rendered: RenderedSegment,
|
|
97
|
-
ctx: SegmentContext,
|
|
98
|
-
): RenderedSegment {
|
|
99
|
-
if (!rendered.visible || !rendered.content) return rendered;
|
|
100
|
-
const template = (
|
|
101
|
-
(ctx.options as Record<string, { template?: string } | undefined>)?.[id]
|
|
102
|
-
?.template
|
|
103
|
-
)?.trim();
|
|
104
|
-
if (template) {
|
|
105
|
-
const plain = rendered.content.replace(ANSI_RE, "");
|
|
106
|
-
return {
|
|
107
|
-
...rendered,
|
|
108
|
-
content: template.includes("{value}")
|
|
109
|
-
? template.replace("{value}", plain)
|
|
110
|
-
: template,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
const label = ctx.segmentLabels?.get(id);
|
|
114
|
-
if (label) {
|
|
115
|
-
return { ...rendered, content: `${label} ${rendered.content}` };
|
|
171
|
+
rendered = computed
|
|
172
|
+
? computed.render(ctx)
|
|
173
|
+
: renderCustomSegment(id, ctx);
|
|
174
|
+
} else {
|
|
175
|
+
const segment = SEGMENTS[id];
|
|
176
|
+
rendered = segment ? segment.render(ctx) : { content: "", visible: false };
|
|
116
177
|
}
|
|
117
|
-
return rendered;
|
|
178
|
+
return applySegmentDecoration(id, ctx, rendered);
|
|
118
179
|
}
|