@selesai/code 0.7.0 → 0.8.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +4 -2
  3. package/dist/extensions/agent-browser.test.ts +195 -0
  4. package/dist/extensions/agent-browser.ts +158 -0
  5. package/dist/extensions/inline-skills.test.ts +14 -0
  6. package/dist/extensions/inline-skills.ts +14 -7
  7. package/dist/extensions/package.json +2 -1
  8. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +27 -0
  9. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +153 -0
  10. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +16 -0
  11. package/dist/extensions/pi-powerline-footer/README.md +192 -44
  12. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +19 -4
  13. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +80 -25
  14. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +81 -29
  15. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +1 -1
  16. package/dist/extensions/pi-powerline-footer/cd-command.ts +190 -0
  17. package/dist/extensions/pi-powerline-footer/colors.ts +2 -1
  18. package/dist/extensions/pi-powerline-footer/context-usage.ts +121 -6
  19. package/dist/extensions/pi-powerline-footer/currency-rates.ts +142 -0
  20. package/dist/extensions/pi-powerline-footer/editor-composition.ts +32 -0
  21. package/dist/extensions/pi-powerline-footer/git-status.ts +108 -6
  22. package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
  23. package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
  24. package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
  25. package/dist/extensions/pi-powerline-footer/package.json +10 -6
  26. package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
  27. package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
  28. package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
  29. package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
  30. package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
  31. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
  32. package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
  33. package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
  34. package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
  35. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
  36. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
  37. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
  38. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
  39. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
  40. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
  41. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
  42. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
  43. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +4 -1
  44. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
  45. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
  46. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
  47. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
  48. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
  49. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
  50. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
  51. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
  52. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
  53. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
  54. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
  55. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
  56. package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
  57. package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
  58. package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
  59. package/dist/extensions/pi-powerline-footer/types.ts +74 -29
  60. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
  61. package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
  62. package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
  63. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  64. package/dist/extensions/pi-subagents/package.json +1 -1
  65. package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
  66. package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
  67. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  68. package/dist/extensions/ponytail/index.js +1 -31
  69. package/dist/extensions/ponytail/test/extension.test.js +5 -22
  70. package/dist/extensions/rtk.test.ts +27 -12
  71. package/dist/extensions/rtk.ts +41 -35
  72. package/package.json +1 -1
@@ -0,0 +1,249 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { computeSessionTokenStats, SessionBranchCache, SessionTokenStatsCache } from "../token-stats.ts";
5
+
6
+ function makeUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0, costPerToken = 0.001) {
7
+ return {
8
+ input,
9
+ output,
10
+ cacheRead,
11
+ cacheWrite,
12
+ cost: { total: (input + output + cacheRead + cacheWrite) * costPerToken },
13
+ };
14
+ }
15
+
16
+ function assistantEvent(usage: ReturnType<typeof makeUsage>, stopReason: string = "stop") {
17
+ return {
18
+ type: "message",
19
+ message: {
20
+ role: "assistant",
21
+ content: [{ type: "text", text: "hi" }],
22
+ usage,
23
+ stopReason,
24
+ },
25
+ };
26
+ }
27
+
28
+ function userEvent() {
29
+ return {
30
+ type: "message",
31
+ message: { role: "user", content: [{ type: "text", text: "hello" }] },
32
+ };
33
+ }
34
+
35
+ function subagentToolResultEvent(costs: number[]) {
36
+ return {
37
+ type: "message",
38
+ message: {
39
+ role: "toolResult",
40
+ toolName: "subagent",
41
+ details: { results: costs.map((cost) => ({ usage: { cost } })) },
42
+ },
43
+ };
44
+ }
45
+
46
+ function subagentSlashResultEvent(costs: number[]) {
47
+ return {
48
+ type: "custom_message",
49
+ customType: "subagent-slash-result",
50
+ details: { result: { details: { results: costs.map((cost) => ({ usage: { cost } })) } } },
51
+ };
52
+ }
53
+
54
+ test("session branch cache reuses a leaf path and refreshes when the leaf changes", () => {
55
+ const cache = new SessionBranchCache();
56
+ let leafId = "leaf-1";
57
+ let branch: unknown[] = [{ id: leafId, value: 1 }];
58
+ let branchReads = 0;
59
+ const provider = {
60
+ getLeafId: () => leafId,
61
+ getBranch: () => {
62
+ branchReads += 1;
63
+ return branch;
64
+ },
65
+ };
66
+
67
+ assert.deepEqual(cache.get(null), []);
68
+
69
+ const first = cache.get(provider);
70
+ (branch[0] as { value: number }).value = 2;
71
+ assert.equal(cache.get(provider), first);
72
+ assert.equal((cache.get(provider)[0] as { value: number }).value, 2);
73
+ assert.equal(branchReads, 1);
74
+
75
+ leafId = "leaf-2";
76
+ branch = [...branch, { id: leafId }];
77
+ assert.equal(cache.get(provider), branch);
78
+ assert.equal(branchReads, 2);
79
+
80
+ cache.reset();
81
+ cache.get(provider);
82
+ assert.equal(branchReads, 3);
83
+ });
84
+
85
+ test("computeSessionTokenStats aggregates usage and tracks thinking level", () => {
86
+ const events = [
87
+ userEvent(),
88
+ { type: "thinking_level_change", thinkingLevel: "high" },
89
+ assistantEvent(makeUsage(10, 5)),
90
+ assistantEvent(makeUsage(20, 7, 3, 2), "error"), // skipped: error
91
+ assistantEvent(makeUsage(30, 9, 4, 1), "aborted"), // skipped: aborted
92
+ assistantEvent(makeUsage(40, 11, 6, 2)),
93
+ ];
94
+
95
+ const stats = computeSessionTokenStats(events);
96
+
97
+ assert.equal(stats.input, 50);
98
+ assert.equal(stats.output, 16);
99
+ assert.equal(stats.cacheRead, 6);
100
+ assert.equal(stats.cacheWrite, 2);
101
+ assert.ok(Math.abs(stats.cost - 0.074) < 1e-12);
102
+ assert.equal(stats.lastAssistant, (events[5] as any).message);
103
+ assert.equal(stats.thinkingLevelFromSession, "high");
104
+ });
105
+
106
+ test("computeSessionTokenStats sums subagent child run cost from tool results and slash results", () => {
107
+ const events = [
108
+ userEvent(),
109
+ assistantEvent(makeUsage(10, 5)),
110
+ subagentToolResultEvent([0.12, 0.34]),
111
+ subagentSlashResultEvent([0.5]),
112
+ ];
113
+
114
+ const stats = computeSessionTokenStats(events);
115
+ assert.ok(Math.abs(stats.subagentCost - 0.96) < 1e-12);
116
+ });
117
+
118
+ test("computeSessionTokenStats ignores assistant messages without tokens for lastAssistant", () => {
119
+ const events = [
120
+ assistantEvent(makeUsage(10, 5)),
121
+ assistantEvent(makeUsage(0, 0)),
122
+ ];
123
+
124
+ const stats = computeSessionTokenStats(events);
125
+ assert.equal(stats.lastAssistant, (events[0] as any).message);
126
+ });
127
+
128
+ test("cache reuses stats object while session is unchanged", () => {
129
+ const cache = new SessionTokenStatsCache();
130
+ const events = [userEvent(), assistantEvent(makeUsage(10, 5))];
131
+
132
+ const first = cache.get(events);
133
+ const second = cache.get(events);
134
+
135
+ assert.equal(second, first);
136
+ });
137
+
138
+ test("cache recomputes when events are appended", () => {
139
+ const cache = new SessionTokenStatsCache();
140
+ const events = [assistantEvent(makeUsage(10, 5))];
141
+
142
+ const first = cache.get(events);
143
+ events.push(assistantEvent(makeUsage(20, 7)));
144
+ const second = cache.get(events);
145
+
146
+ assert.notEqual(second, first);
147
+ assert.equal(second.input, 30);
148
+ assert.equal(second.output, 12);
149
+ });
150
+
151
+ test("cache recomputes when the last event is updated in place (streaming)", () => {
152
+ const cache = new SessionTokenStatsCache();
153
+ const tail = assistantEvent(makeUsage(10, 5), "streaming");
154
+ const events = [userEvent(), tail];
155
+
156
+ const first = cache.get(events);
157
+ assert.equal(first.output, 5);
158
+
159
+ // Streaming updates the trailing assistant message in place: same event
160
+ // count, same object references, fresh usage numbers.
161
+ tail.message.usage = makeUsage(10, 42);
162
+ tail.message.stopReason = "stop";
163
+
164
+ const second = cache.get(events);
165
+ assert.notEqual(second, first);
166
+ assert.equal(second.output, 42);
167
+ assert.equal(second.lastAssistant, tail.message);
168
+ });
169
+
170
+ test("cache recomputes when the last subagent result cost is updated in place", () => {
171
+ const cache = new SessionTokenStatsCache();
172
+ const tail = subagentToolResultEvent([0.1]);
173
+ const events = [assistantEvent(makeUsage(10, 5)), tail];
174
+
175
+ const first = cache.get(events);
176
+ assert.equal(first.subagentCost, 0.1);
177
+
178
+ tail.message.details.results = [{ usage: { cost: 0.3 } }];
179
+
180
+ const second = cache.get(events);
181
+ assert.notEqual(second, first);
182
+ assert.equal(second.subagentCost, 0.3);
183
+ });
184
+
185
+ test("cache recomputes when the last event turns into an error in place", () => {
186
+ const cache = new SessionTokenStatsCache();
187
+ const tail = assistantEvent(makeUsage(10, 5));
188
+ const events = [assistantEvent(makeUsage(1, 1)), tail];
189
+
190
+ const first = cache.get(events);
191
+ assert.equal(first.input, 11);
192
+
193
+ tail.message.stopReason = "error";
194
+
195
+ const second = cache.get(events);
196
+ assert.notEqual(second, first);
197
+ assert.equal(second.input, 1);
198
+ });
199
+
200
+ test("cache recomputes when the last event is replaced with a same-sized list", () => {
201
+ const cache = new SessionTokenStatsCache();
202
+ const events = [userEvent(), assistantEvent(makeUsage(10, 5))];
203
+
204
+ const first = cache.get(events);
205
+ events[1] = assistantEvent(makeUsage(10, 5));
206
+ const second = cache.get(events);
207
+
208
+ assert.notEqual(second, first);
209
+ assert.deepEqual(
210
+ { input: second.input, output: second.output },
211
+ { input: first.input, output: first.output },
212
+ );
213
+ });
214
+
215
+ test("cache does not rescan for in-place changes to non-trailing events", () => {
216
+ const cache = new SessionTokenStatsCache();
217
+ const head = assistantEvent(makeUsage(10, 5));
218
+ const events = [head, assistantEvent(makeUsage(20, 7))];
219
+
220
+ const first = cache.get(events);
221
+
222
+ // Historical events are treated as immutable; only the trailing event is
223
+ // re-validated. This documents the cache's correctness boundary.
224
+ head.message.usage = makeUsage(999, 999);
225
+ const second = cache.get(events);
226
+
227
+ assert.equal(second, first);
228
+ assert.equal(second.input, 30);
229
+ });
230
+
231
+ test("reset forces recomputation", () => {
232
+ const cache = new SessionTokenStatsCache();
233
+ const events = [assistantEvent(makeUsage(10, 5))];
234
+
235
+ const first = cache.get(events);
236
+ cache.reset();
237
+ const second = cache.get(events);
238
+
239
+ assert.notEqual(second, first);
240
+ assert.equal(second.input, first.input);
241
+ });
242
+
243
+ test("index.ts wires the token stats cache into buildSegmentContext", () => {
244
+ const source = readFileSync(new URL("../index.ts", import.meta.url), "utf-8");
245
+ assert.match(source, /import \{ SessionBranchCache, SessionTokenStatsCache \} from "\.\/token-stats\.ts";/);
246
+ assert.match(source, /sessionBranchCache\.get\(ctx\.sessionManager\)/);
247
+ assert.match(source, /tokenStatsCache\.get\(sessionEvents\)/);
248
+ assert.match(source, /tokenStatsCache\.reset\(\)/);
249
+ });
@@ -0,0 +1,237 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { renderSegment } from "../segments.ts";
4
+ import { mergeSegmentOptions, parsePowerlineConfig } from "../powerline-config.ts";
5
+ import type { SegmentContext, StatusLineSegmentOptions } from "../types.ts";
6
+
7
+ const PRESET_NAMES = ["default", "compact"];
8
+
9
+ const originalNerdFonts = process.env.POWERLINE_NERD_FONTS;
10
+ process.env.POWERLINE_NERD_FONTS = "0";
11
+
12
+ test.after(() => {
13
+ if (originalNerdFonts === undefined) {
14
+ delete process.env.POWERLINE_NERD_FONTS;
15
+ } else {
16
+ process.env.POWERLINE_NERD_FONTS = originalNerdFonts;
17
+ }
18
+ });
19
+
20
+ function stripAnsi(text: string): string {
21
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
22
+ }
23
+
24
+ function plainTheme(): any {
25
+ return {
26
+ fg: (_color: string, text: string) => text,
27
+ bg: (_color: string, text: string) => text,
28
+ bold: (text: string) => text,
29
+ };
30
+ }
31
+
32
+ function createSegmentContext(options: StatusLineSegmentOptions = {}, overrides: Partial<SegmentContext> = {}): SegmentContext {
33
+ return {
34
+ model: undefined,
35
+ thinkingLevel: "off",
36
+ sessionId: undefined,
37
+ usageStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, subagentCost: 0 },
38
+ contextTokens: 0,
39
+ contextPercent: 0,
40
+ contextWindow: 0,
41
+ contextApproximate: false,
42
+ autoCompactEnabled: true,
43
+ customCompactionEnabled: false,
44
+ usingSubscription: false,
45
+ queueSummary: { queueCount: 0, ideaCount: 0, blockedCount: 0, compacting: false, leadingText: null, leadingIntent: null, leadingStatus: null },
46
+ sessionStartTime: Date.now(),
47
+ shellModeActive: false,
48
+ shellRunning: false,
49
+ shellName: null,
50
+ shellCwd: null,
51
+ git: { branch: null, staged: 0, unstaged: 0, untracked: 0 },
52
+ extensionStatuses: new Map(),
53
+ hiddenExtensionStatusKeys: new Set(),
54
+ customItemsById: new Map(),
55
+ options,
56
+ theme: plainTheme(),
57
+ colors: {},
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ // ── context_pct format ──────────────────────────────────────────────────────
63
+
64
+ test("context_pct defaults to the full tokens/window rendering", () => {
65
+ const ctx = createSegmentContext({}, {
66
+ contextTokens: 12300,
67
+ contextWindow: 200000,
68
+ contextPercent: 6.15,
69
+ });
70
+
71
+ const rendered = renderSegment("context_pct", ctx);
72
+ assert.equal(stripAnsi(rendered.content), "12k/200k (6.2%)");
73
+ });
74
+
75
+ test("context_pct percent format renders a bare rounded percentage", () => {
76
+ const ctx = createSegmentContext({ context: { format: "percent" } }, {
77
+ contextTokens: 12300,
78
+ contextWindow: 200000,
79
+ contextPercent: 6.15,
80
+ });
81
+
82
+ const rendered = renderSegment("context_pct", ctx);
83
+ assert.equal(stripAnsi(rendered.content), "6%");
84
+ });
85
+
86
+ test("context_pct renders unknown usage after compaction", () => {
87
+ const full = createSegmentContext({}, {
88
+ contextTokens: null,
89
+ contextWindow: 200000,
90
+ contextPercent: null,
91
+ });
92
+ const percent = createSegmentContext({ context: { format: "percent" } }, {
93
+ contextTokens: null,
94
+ contextWindow: 200000,
95
+ contextPercent: null,
96
+ });
97
+
98
+ assert.equal(stripAnsi(renderSegment("context_pct", full).content), "?/200k");
99
+ assert.equal(stripAnsi(renderSegment("context_pct", percent).content), "?");
100
+ });
101
+
102
+ test("context_pct marks context estimates as approximate", () => {
103
+ const full = createSegmentContext({}, {
104
+ contextTokens: 18000,
105
+ contextWindow: 272000,
106
+ contextPercent: 6.6176,
107
+ contextApproximate: true,
108
+ });
109
+ const percent = createSegmentContext({ context: { format: "percent" } }, {
110
+ contextTokens: 18000,
111
+ contextWindow: 272000,
112
+ contextPercent: 6.6176,
113
+ contextApproximate: true,
114
+ });
115
+
116
+ assert.equal(stripAnsi(renderSegment("context_pct", full).content), "~18k/272k (6.6%)");
117
+ assert.equal(stripAnsi(renderSegment("context_pct", percent).content), "~7%");
118
+ });
119
+
120
+ test("context_pct percent format keeps threshold colors and drops icons", () => {
121
+ for (const [percent, expected] of [[69, "69%"], [85, "85%"], [95, "95%"]] as const) {
122
+ const ctx = createSegmentContext({ context: { format: "percent" } }, {
123
+ contextTokens: percent,
124
+ contextWindow: 100,
125
+ contextPercent: percent,
126
+ });
127
+ const rendered = renderSegment("context_pct", ctx);
128
+ assert.equal(stripAnsi(rendered.content), expected);
129
+ assert.ok(!rendered.content.includes("◫"), "no context icon in percent mode");
130
+ assert.ok(!rendered.content.includes("AC"), "no auto-compact icon in percent mode");
131
+ }
132
+ });
133
+
134
+ // ── cache_read format ───────────────────────────────────────────────────────
135
+
136
+ test("cache_read defaults to raw token count", () => {
137
+ const ctx = createSegmentContext({}, {
138
+ usageStats: { input: 1000, output: 0, cacheRead: 12300, cacheWrite: 0, cost: 0, subagentCost: 0 },
139
+ });
140
+
141
+ const rendered = renderSegment("cache_read", ctx);
142
+ assert.equal(stripAnsi(rendered.content), "cache in: 12k");
143
+ });
144
+
145
+ test("cache_read percent format renders the cache hit rate", () => {
146
+ const ctx = createSegmentContext({ cache_read: { format: "percent" } }, {
147
+ usageStats: { input: 2000, output: 0, cacheRead: 8000, cacheWrite: 0, cost: 0, subagentCost: 0 },
148
+ });
149
+
150
+ const rendered = renderSegment("cache_read", ctx);
151
+ assert.equal(stripAnsi(rendered.content), "cache 80%");
152
+ });
153
+
154
+ test("cache_read both format renders raw token count and cache hit rate", () => {
155
+ const ctx = createSegmentContext({ cache_read: { format: "both" } }, {
156
+ usageStats: { input: 2000, output: 0, cacheRead: 8000, cacheWrite: 0, cost: 0, subagentCost: 0 },
157
+ });
158
+
159
+ const rendered = renderSegment("cache_read", ctx);
160
+ assert.equal(stripAnsi(rendered.content), "cache in: 8.0k (80%)");
161
+ });
162
+
163
+ test("cache_read percent and both formats handle zero input without NaN", () => {
164
+ const percentCtx = createSegmentContext({ cache_read: { format: "percent" } }, {
165
+ usageStats: { input: 0, output: 0, cacheRead: 5, cacheWrite: 0, cost: 0, subagentCost: 0 },
166
+ });
167
+ assert.equal(stripAnsi(renderSegment("cache_read", percentCtx).content), "cache 100%");
168
+
169
+ const bothCtx = createSegmentContext({ cache_read: { format: "both" } }, {
170
+ usageStats: { input: 0, output: 0, cacheRead: 5, cacheWrite: 0, cost: 0, subagentCost: 0 },
171
+ });
172
+ assert.equal(stripAnsi(renderSegment("cache_read", bothCtx).content), "cache in: 5 (100%)");
173
+
174
+ const hidden = createSegmentContext({ cache_read: { format: "both" } });
175
+ assert.deepEqual(renderSegment("cache_read", hidden), { content: "", visible: false });
176
+ });
177
+
178
+ // ── queue segment ──────────────────────────────────────────────────────────
179
+
180
+ test("queue segment hides when empty", () => {
181
+ const ctx = createSegmentContext();
182
+ assert.deepEqual(renderSegment("queue", ctx), { content: "", visible: false });
183
+ });
184
+
185
+ test("queue segment summarizes queued ideas and blocked items", () => {
186
+ const ctx = createSegmentContext({}, {
187
+ queueSummary: { queueCount: 2, ideaCount: 3, blockedCount: 1, compacting: false, leadingText: "fix README", leadingIntent: "post-compact", leadingStatus: "blocked" },
188
+ });
189
+
190
+ assert.equal(stripAnsi(renderSegment("queue", ctx).content), "q 2 · ideas 3 · blocked 1");
191
+ });
192
+
193
+ test("queue segment highlights compaction-held prompts", () => {
194
+ const ctx = createSegmentContext({}, {
195
+ queueSummary: { queueCount: 1, ideaCount: 0, blockedCount: 0, compacting: true, leadingText: "run after compact", leadingIntent: "post-compact", leadingStatus: "queued" },
196
+ });
197
+
198
+ assert.equal(stripAnsi(renderSegment("queue", ctx).content), "compact q 1");
199
+ });
200
+
201
+ // ── config parsing / merging ────────────────────────────────────────────────
202
+
203
+ test("parsePowerlineConfig accepts context and cache_read formats", () => {
204
+ const config = parsePowerlineConfig({
205
+ context: { format: "percent" },
206
+ cache_read: { format: "both" },
207
+ }, PRESET_NAMES);
208
+
209
+ assert.equal(config.segmentOptions.context?.format, "percent");
210
+ assert.equal(config.segmentOptions.cache_read?.format, "both");
211
+ });
212
+
213
+ test("parsePowerlineConfig ignores invalid format values", () => {
214
+ const config = parsePowerlineConfig({
215
+ context: { format: "bogus" },
216
+ cache_read: { format: 42 },
217
+ }, PRESET_NAMES);
218
+
219
+ assert.equal(config.segmentOptions.context?.format, undefined);
220
+ assert.equal(config.segmentOptions.cache_read?.format, undefined);
221
+ });
222
+
223
+ test("parsePowerlineConfig defaults to upstream rendering when options are absent", () => {
224
+ const config = parsePowerlineConfig({}, PRESET_NAMES);
225
+ assert.equal(config.segmentOptions.context, undefined);
226
+ assert.equal(config.segmentOptions.cache_read, undefined);
227
+ });
228
+
229
+ test("mergeSegmentOptions merges context and cache_read per key", () => {
230
+ const merged = mergeSegmentOptions(
231
+ { context: { format: "percent" }, cache_read: { format: "percent" } },
232
+ { context: { format: "full" }, cache_read: { format: "both" } },
233
+ );
234
+
235
+ assert.equal(merged.context?.format, "full");
236
+ assert.equal(merged.cache_read?.format, "both");
237
+ });
@@ -0,0 +1,199 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { discoverLoadedCounts, getRecentSessions, WelcomeHeader } from "../welcome.ts";
7
+
8
+ const indexSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
9
+
10
+ test("discoverLoadedCounts ignores dangling skill symlinks", () => {
11
+ const root = mkdtempSync(join(tmpdir(), "powerline-welcome-"));
12
+ const home = join(root, "home");
13
+ const project = join(root, "project");
14
+ const skillsDir = join(home, ".selesai", "agent", "skills");
15
+ const originalHome = process.env.HOME;
16
+ const originalAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
17
+ const originalCwd = process.cwd();
18
+ const originalDebug = console.debug;
19
+ const debugCalls: unknown[][] = [];
20
+
21
+ mkdirSync(join(skillsDir, "valid-skill"), { recursive: true });
22
+ mkdirSync(project, { recursive: true });
23
+ writeFileSync(join(skillsDir, "valid-skill", "SKILL.md"), "# Valid skill\n");
24
+ symlinkSync(join(root, "missing-skill"), join(skillsDir, "pi-intercom"), "dir");
25
+
26
+ console.debug = (...args: unknown[]) => {
27
+ debugCalls.push(args);
28
+ };
29
+
30
+ try {
31
+ process.env.HOME = home;
32
+ delete process.env.SELESAI_CODING_AGENT_DIR;
33
+ process.chdir(project);
34
+
35
+ assert.equal(discoverLoadedCounts().skills, 1);
36
+ assert.deepEqual(debugCalls, []);
37
+ } finally {
38
+ console.debug = originalDebug;
39
+ process.chdir(originalCwd);
40
+ if (originalHome === undefined) {
41
+ delete process.env.HOME;
42
+ } else {
43
+ process.env.HOME = originalHome;
44
+ }
45
+ if (originalAgentDir === undefined) {
46
+ delete process.env.SELESAI_CODING_AGENT_DIR;
47
+ } else {
48
+ process.env.SELESAI_CODING_AGENT_DIR = originalAgentDir;
49
+ }
50
+ rmSync(root, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ function withTemporaryHome(run: (home: string) => void): void {
55
+ const home = mkdtempSync(join(tmpdir(), "powerline-welcome-home-"));
56
+ const originalHome = process.env.HOME;
57
+ const originalAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
58
+
59
+ try {
60
+ process.env.HOME = home;
61
+ delete process.env.SELESAI_CODING_AGENT_DIR;
62
+ run(home);
63
+ } finally {
64
+ if (originalHome === undefined) {
65
+ delete process.env.HOME;
66
+ } else {
67
+ process.env.HOME = originalHome;
68
+ }
69
+ if (originalAgentDir === undefined) {
70
+ delete process.env.SELESAI_CODING_AGENT_DIR;
71
+ } else {
72
+ process.env.SELESAI_CODING_AGENT_DIR = originalAgentDir;
73
+ }
74
+ rmSync(home, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ test("welcome renders the initial system prompt token estimate", () => {
79
+ const counts = { contextFiles: 1, extensions: 1, skills: 1, promptTemplates: 1 };
80
+ const rendered = new WelcomeHeader("Model", "Provider", [], counts, "compact", [], 1900)
81
+ .render(96)
82
+ .join("\n")
83
+ .replace(/\x1b\[[0-9;]*m/g, "");
84
+ const withoutEstimate = [undefined, 0, Number.NaN].map((tokens) => new WelcomeHeader(
85
+ "Model",
86
+ "Provider",
87
+ [],
88
+ counts,
89
+ "compact",
90
+ [],
91
+ tokens,
92
+ ).render(96).join("\n").replace(/\x1b\[[0-9;]*m/g, ""));
93
+
94
+ assert.match(rendered, /≈ 1\.9k initial prompt tokens/);
95
+ for (const output of withoutEstimate) {
96
+ assert.doesNotMatch(output, /initial prompt tokens/);
97
+ }
98
+ assert.match(indexSource, /new WelcomeHeader\(modelName, providerName, recentSessions, loadedCounts, "compact", newFeatures, initialContextTokens\)/);
99
+
100
+ const overlayStart = indexSource.indexOf("function setupWelcomeOverlay");
101
+ const delayStart = indexSource.indexOf("setTimeout(() => {", overlayStart);
102
+ const activityGuardStart = indexSource.indexOf("if (hasActivity) {", delayStart);
103
+ const estimateStart = indexSource.indexOf("const initialContextTokens = estimateInitialContextTokens(ctx);", overlayStart);
104
+ const componentStart = indexSource.indexOf("new WelcomeComponent(", overlayStart);
105
+ assert.ok(overlayStart >= 0);
106
+ assert.ok(delayStart > overlayStart);
107
+ assert.ok(activityGuardStart > delayStart);
108
+ assert.ok(estimateStart > activityGuardStart);
109
+ assert.ok(componentStart > estimateStart);
110
+ });
111
+
112
+ test("getRecentSessions prefers cwd basename from session header", () => {
113
+ withTemporaryHome((home) => {
114
+ const sessionsDir = join(home, ".selesai", "agent", "sessions", "--Users-nico-dev-encoded-name--");
115
+ mkdirSync(sessionsDir, { recursive: true });
116
+ writeFileSync(join(sessionsDir, "session.jsonl"), JSON.stringify({ cwd: "/Users/nico/dev/my-dashed-project" }) + "\n");
117
+
118
+ assert.equal(getRecentSessions(1)[0]?.name, "my-dashed-project");
119
+ });
120
+ });
121
+
122
+ test("getRecentSessions falls back to encoded directory when header cwd is unusable", () => {
123
+ withTemporaryHome((home) => {
124
+ const root = join(home, ".selesai", "agent", "sessions");
125
+ const cases = [
126
+ ["invalid-json", "not-json\n"],
127
+ ["missing-cwd", JSON.stringify({ type: "session" }) + "\n"],
128
+ ["non-string-cwd", JSON.stringify({ cwd: 123 }) + "\n"],
129
+ ];
130
+
131
+ for (const [name, content] of cases) {
132
+ const sessionsDir = join(root, `--Users-nico-dev-${name}--`);
133
+ mkdirSync(sessionsDir, { recursive: true });
134
+ writeFileSync(join(sessionsDir, "session.jsonl"), content);
135
+ }
136
+
137
+ const names = getRecentSessions(10).map((session) => session.name);
138
+ assert.ok(names.includes("json"));
139
+ assert.ok(names.includes("cwd"));
140
+ });
141
+ });
142
+
143
+ test("welcome discovery respects SELESAI_CODING_AGENT_DIR for agent-global files", () => {
144
+ withTemporaryHome((home) => {
145
+ const root = mkdtempSync(join(tmpdir(), "powerline-welcome-agent-dir-"));
146
+ const project = join(root, "project");
147
+ const agentDir = join(root, "agent-dir");
148
+ const originalCwd = process.cwd();
149
+
150
+ try {
151
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
152
+ mkdirSync(project, { recursive: true });
153
+ mkdirSync(join(agentDir, "extensions", "local-ext"), { recursive: true });
154
+ mkdirSync(join(agentDir, "skills", "skill-a"), { recursive: true });
155
+ mkdirSync(join(agentDir, "commands"), { recursive: true });
156
+ mkdirSync(join(home, ".selesai", "agent"), { recursive: true });
157
+ writeFileSync(join(agentDir, "AGENTS.md"), "# Agent instructions\n");
158
+ writeFileSync(join(agentDir, "extensions", "local-ext", "index.ts"), "export default {};\n");
159
+ writeFileSync(join(agentDir, "skills", "skill-a", "SKILL.md"), "# Skill\n");
160
+ writeFileSync(join(agentDir, "commands", "hello.md"), "hello\n");
161
+ writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ packages: ["npm:pkg-one@1.0.0"] }));
162
+ writeFileSync(join(home, ".selesai", "agent", "AGENTS.md"), "# Should not count\n");
163
+ process.chdir(project);
164
+
165
+ assert.deepEqual(discoverLoadedCounts(), {
166
+ contextFiles: 1,
167
+ extensions: 2,
168
+ skills: 1,
169
+ promptTemplates: 1,
170
+ });
171
+ } finally {
172
+ process.chdir(originalCwd);
173
+ rmSync(root, { recursive: true, force: true });
174
+ }
175
+ });
176
+ });
177
+
178
+ test("getRecentSessions reads custom agent sessions and existing legacy sessions", () => {
179
+ withTemporaryHome((home) => {
180
+ const root = mkdtempSync(join(tmpdir(), "powerline-welcome-sessions-"));
181
+ const agentDir = join(root, "agent-dir");
182
+
183
+ try {
184
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
185
+ const customSessionDir = join(agentDir, "sessions", "--custom--");
186
+ const legacySessionDir = join(home, ".selesai", "sessions", "--legacy--");
187
+ mkdirSync(customSessionDir, { recursive: true });
188
+ mkdirSync(legacySessionDir, { recursive: true });
189
+ writeFileSync(join(customSessionDir, "session.jsonl"), JSON.stringify({ cwd: "/tmp/custom-project" }) + "\n");
190
+ writeFileSync(join(legacySessionDir, "session.jsonl"), JSON.stringify({ cwd: "/tmp/legacy-project" }) + "\n");
191
+
192
+ const names = getRecentSessions(10).map((session) => session.name);
193
+ assert.ok(names.includes("custom-project"));
194
+ assert.ok(names.includes("legacy-project"));
195
+ } finally {
196
+ rmSync(root, { recursive: true, force: true });
197
+ }
198
+ });
199
+ });