@selesai/code 0.7.0 → 0.8.1

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 (73) hide show
  1. package/CHANGELOG.md +16 -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 +156 -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/guide.ts +1 -1
  23. package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
  24. package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
  25. package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
  26. package/dist/extensions/pi-powerline-footer/package.json +10 -6
  27. package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
  28. package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
  29. package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
  30. package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
  31. package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
  32. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
  33. package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
  34. package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
  35. package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
  36. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
  37. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
  38. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
  39. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
  40. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
  41. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
  42. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
  43. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
  44. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +11 -6
  45. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
  46. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
  47. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
  48. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
  49. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
  50. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
  51. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
  52. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
  53. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
  54. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
  55. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
  56. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
  57. package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
  58. package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
  59. package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
  60. package/dist/extensions/pi-powerline-footer/types.ts +74 -29
  61. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
  62. package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
  63. package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
  64. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  65. package/dist/extensions/pi-subagents/package.json +1 -1
  66. package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
  67. package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
  68. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  69. package/dist/extensions/ponytail/index.js +1 -31
  70. package/dist/extensions/ponytail/test/extension.test.js +5 -22
  71. package/dist/extensions/rtk.test.ts +27 -12
  72. package/dist/extensions/rtk.ts +41 -35
  73. package/package.json +1 -1
@@ -0,0 +1,304 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+
3
+ type SessionAssistantUsage = AssistantMessage["usage"];
4
+
5
+ export interface SessionTokenStats {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ cost: number;
11
+ subagentCost: number;
12
+ lastAssistant: AssistantMessage | undefined;
13
+ thinkingLevelFromSession: string | null;
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ function hasSessionAssistantUsage(value: unknown): value is SessionAssistantUsage {
21
+ if (!isRecord(value)) {
22
+ return false;
23
+ }
24
+
25
+ if (
26
+ typeof value.input !== "number" ||
27
+ typeof value.output !== "number" ||
28
+ typeof value.cacheRead !== "number" ||
29
+ typeof value.cacheWrite !== "number"
30
+ ) {
31
+ return false;
32
+ }
33
+
34
+ return isRecord(value.cost) && typeof value.cost.total === "number";
35
+ }
36
+
37
+ function isSessionAssistantMessage(value: unknown): value is AssistantMessage {
38
+ return isRecord(value)
39
+ && value.role === "assistant"
40
+ && hasSessionAssistantUsage(value.usage)
41
+ && (value.stopReason === undefined || typeof value.stopReason === "string");
42
+ }
43
+
44
+ function getUsageTokenTotal(usage: SessionAssistantUsage): number {
45
+ const totalTokens = "totalTokens" in usage && typeof usage.totalTokens === "number" ? usage.totalTokens : 0;
46
+ return totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
47
+ }
48
+
49
+ // Matches pi-subagents' internal SLASH_RESULT_TYPE custom_message marker.
50
+ const SUBAGENT_SLASH_RESULT_TYPE = "subagent-slash-result";
51
+
52
+ function subagentDetailsFromSessionEntry(e: Record<string, unknown>): { results: unknown[] } | undefined {
53
+ if (e.type === "custom_message" && e.customType === SUBAGENT_SLASH_RESULT_TYPE) {
54
+ const details = isRecord(e.details) ? e.details : undefined;
55
+ const result = isRecord(details?.result) ? details.result : undefined;
56
+ const inner = isRecord(result?.details) ? result.details : undefined;
57
+ return Array.isArray(inner?.results) ? { results: inner.results } : undefined;
58
+ }
59
+ if (e.type === "message" && isRecord(e.message)) {
60
+ const message = e.message as { role?: unknown; toolName?: unknown; details?: unknown };
61
+ if (message.role === "toolResult" && message.toolName === "subagent" && isRecord(message.details) && Array.isArray(message.details.results)) {
62
+ return { results: message.details.results };
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ // Sums subagent child usage cost recorded on a single session entry (parallel/chain/single runs
69
+ // launched via the subagent tool or /parallel, /worker etc. slash commands), so the footer can
70
+ // show total spend rather than only the interactive parent session's cost.
71
+ function extractSubagentResultCost(e: Record<string, unknown>): number {
72
+ const details = subagentDetailsFromSessionEntry(e);
73
+ if (!details) return 0;
74
+ let total = 0;
75
+ for (const result of details.results) {
76
+ if (!isRecord(result)) continue;
77
+ const usage = isRecord(result.usage) ? result.usage : undefined;
78
+ if (typeof usage?.cost === "number") total += usage.cost;
79
+ }
80
+ return total;
81
+ }
82
+
83
+ /**
84
+ * Fingerprint of the fields on a session event that influence the aggregated
85
+ * stats. Comparing only the event count is not enough: while streaming, pi
86
+ * may update the trailing assistant message in place (usage grows, stopReason
87
+ * flips), so the same event reference can carry fresh numbers.
88
+ */
89
+ function eventStatsSignature(event: unknown): string {
90
+ if (!isRecord(event)) {
91
+ return "?";
92
+ }
93
+
94
+ if (event.type === "thinking_level_change") {
95
+ return `t:${typeof event.thinkingLevel === "string" ? event.thinkingLevel : ""}`;
96
+ }
97
+
98
+ const subagentDetails = subagentDetailsFromSessionEntry(event);
99
+ if (subagentDetails) {
100
+ return `s:${subagentDetails.results.length}:${extractSubagentResultCost(event)}`;
101
+ }
102
+
103
+ if (event.type === "message" && isRecord(event.message)) {
104
+ const message = event.message;
105
+ const role = typeof message.role === "string" ? message.role : "";
106
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
107
+ if (role === "assistant" && hasSessionAssistantUsage(message.usage)) {
108
+ const usage = message.usage;
109
+ return `a:${stopReason}:${usage.input}/${usage.output}/${usage.cacheRead}/${usage.cacheWrite}/${usage.cost.total}`;
110
+ }
111
+ return `m:${role}:${stopReason}`;
112
+ }
113
+
114
+ return `e:${typeof event.type === "string" ? event.type : "?"}`;
115
+ }
116
+
117
+ function emptySessionTokenStats(): SessionTokenStats {
118
+ return {
119
+ input: 0,
120
+ output: 0,
121
+ cacheRead: 0,
122
+ cacheWrite: 0,
123
+ cost: 0,
124
+ subagentCost: 0,
125
+ lastAssistant: undefined,
126
+ thinkingLevelFromSession: null,
127
+ };
128
+ }
129
+
130
+ function copySessionTokenStats(stats: SessionTokenStats): SessionTokenStats {
131
+ return { ...stats };
132
+ }
133
+
134
+ function accumulateSessionEvent(stats: SessionTokenStats, event: unknown): void {
135
+ if (!isRecord(event)) return;
136
+
137
+ if (event.type === "thinking_level_change" && typeof event.thinkingLevel === "string") {
138
+ stats.thinkingLevelFromSession = event.thinkingLevel;
139
+ }
140
+
141
+ stats.subagentCost += extractSubagentResultCost(event);
142
+
143
+ if (event.type !== "message" || !isSessionAssistantMessage(event.message)) return;
144
+
145
+ const message = event.message;
146
+ if (message.stopReason === "error" || message.stopReason === "aborted") return;
147
+
148
+ stats.input += message.usage.input;
149
+ stats.output += message.usage.output;
150
+ stats.cacheRead += message.usage.cacheRead;
151
+ stats.cacheWrite += message.usage.cacheWrite;
152
+ stats.cost += message.usage.cost.total;
153
+ if (getUsageTokenTotal(message.usage) > 0) {
154
+ stats.lastAssistant = message;
155
+ }
156
+ }
157
+
158
+ export interface SessionBranchProvider {
159
+ getLeafId(): string | null;
160
+ getBranch(): readonly unknown[];
161
+ }
162
+
163
+ function isSessionBranchProvider(value: unknown): value is SessionBranchProvider {
164
+ return isRecord(value) && typeof value.getLeafId === "function" && typeof value.getBranch === "function";
165
+ }
166
+
167
+ /**
168
+ * Session branches are immutable for a given leaf. Keep the already-built path
169
+ * while streaming updates mutate only its trailing message in place.
170
+ */
171
+ export class SessionBranchCache {
172
+ private provider: SessionBranchProvider | null = null;
173
+ private leafId: string | null = null;
174
+ private branch: readonly unknown[] = [];
175
+
176
+ get(source: unknown): readonly unknown[] {
177
+ if (!isSessionBranchProvider(source)) return [];
178
+
179
+ const provider = source;
180
+ const leafId = provider.getLeafId();
181
+ // A reset cache holds provider === null, which never matches a live provider.
182
+ if (this.provider !== provider || this.leafId !== leafId) {
183
+ this.provider = provider;
184
+ this.leafId = leafId;
185
+ this.branch = provider.getBranch();
186
+ }
187
+ return this.branch;
188
+ }
189
+
190
+ reset(): void {
191
+ this.provider = null;
192
+ this.leafId = null;
193
+ this.branch = [];
194
+ }
195
+ }
196
+
197
+ export function computeSessionTokenStats(sessionEvents: readonly unknown[]): SessionTokenStats {
198
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0, subagentCost = 0;
199
+ let lastAssistant: AssistantMessage | undefined;
200
+ let thinkingLevelFromSession: string | null = null;
201
+
202
+ for (const e of sessionEvents) {
203
+ if (!isRecord(e)) continue;
204
+
205
+ if (e.type === "thinking_level_change" && typeof e.thinkingLevel === "string") {
206
+ thinkingLevelFromSession = e.thinkingLevel;
207
+ }
208
+
209
+ subagentCost += extractSubagentResultCost(e);
210
+
211
+ if (e.type !== "message" || !isSessionAssistantMessage(e.message)) continue;
212
+
213
+ const m = e.message;
214
+ if (m.stopReason === "error" || m.stopReason === "aborted") continue;
215
+
216
+ input += m.usage.input;
217
+ output += m.usage.output;
218
+ cacheRead += m.usage.cacheRead;
219
+ cacheWrite += m.usage.cacheWrite;
220
+ cost += m.usage.cost.total;
221
+ if (getUsageTokenTotal(m.usage) > 0) {
222
+ lastAssistant = m;
223
+ }
224
+ }
225
+
226
+ return { input, output, cacheRead, cacheWrite, cost, subagentCost, lastAssistant, thinkingLevelFromSession };
227
+ }
228
+
229
+ /**
230
+ * Cache for token counting: avoid re-scanning the full session event list on
231
+ * every render (250ms-1s cadence while streaming). Historical session entries
232
+ * are append-only, so appended entries are accumulated and an in-place update
233
+ * to the trailing streaming entry only recomputes that entry.
234
+ */
235
+ export class SessionTokenStatsCache {
236
+ private eventCount = -1;
237
+ private lastEvent: unknown;
238
+ private lastSignature = "";
239
+ private prefixStats: SessionTokenStats | null = null;
240
+ private stats: SessionTokenStats | null = null;
241
+
242
+ get(sessionEvents: readonly unknown[]): SessionTokenStats {
243
+ const eventCount = sessionEvents.length;
244
+ const lastEvent = eventCount > 0 ? sessionEvents[eventCount - 1] : undefined;
245
+ const lastSignature = eventStatsSignature(lastEvent);
246
+
247
+ if (
248
+ this.stats !== null
249
+ && this.eventCount === eventCount
250
+ && this.lastEvent === lastEvent
251
+ && this.lastSignature === lastSignature
252
+ ) {
253
+ return this.stats;
254
+ }
255
+
256
+ // getBranch() walks unique parent links, so an identical trailing event at
257
+ // the previous count proves the whole previous array is an unchanged prefix.
258
+ const previousStats = this.stats;
259
+ const canExtendPreviousStats = previousStats !== null
260
+ && eventCount > this.eventCount
261
+ && (this.eventCount === 0 || (
262
+ sessionEvents[this.eventCount - 1] === this.lastEvent
263
+ && eventStatsSignature(sessionEvents[this.eventCount - 1]) === this.lastSignature
264
+ ));
265
+
266
+ let prefixStats: SessionTokenStats;
267
+ if (canExtendPreviousStats && previousStats !== null) {
268
+ prefixStats = copySessionTokenStats(previousStats);
269
+ for (let index = this.eventCount; index < eventCount - 1; index += 1) {
270
+ accumulateSessionEvent(prefixStats, sessionEvents[index]);
271
+ }
272
+ } else if (
273
+ this.prefixStats !== null
274
+ && this.eventCount === eventCount
275
+ && this.lastEvent === lastEvent
276
+ ) {
277
+ // Same array with an in-place tail mutation: reuse the cached prefix.
278
+ prefixStats = this.prefixStats;
279
+ } else {
280
+ prefixStats = emptySessionTokenStats();
281
+ for (let index = 0; index < eventCount - 1; index += 1) {
282
+ accumulateSessionEvent(prefixStats, sessionEvents[index]);
283
+ }
284
+ }
285
+
286
+ const stats = copySessionTokenStats(prefixStats);
287
+ if (eventCount > 0) accumulateSessionEvent(stats, lastEvent);
288
+
289
+ this.eventCount = eventCount;
290
+ this.lastEvent = lastEvent;
291
+ this.lastSignature = lastSignature;
292
+ this.prefixStats = prefixStats;
293
+ this.stats = stats;
294
+ return stats;
295
+ }
296
+
297
+ reset(): void {
298
+ this.eventCount = -1;
299
+ this.lastEvent = undefined;
300
+ this.lastSignature = "";
301
+ this.prefixStats = null;
302
+ this.stats = null;
303
+ }
304
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "types": ["node"],
10
+ "allowImportingTsExtensions": true
11
+ },
12
+ "include": ["*.ts", "bash-mode/**/*.ts", "fixed-editor/**/*.ts", "queue/**/*.ts"]
13
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Theme, ThemeColor } from "@selesai/code";
2
+ import type { CostCurrencyCode } from "./currency-rates.ts";
2
3
 
3
4
  // Theme color - either a pi theme color name or a custom hex color
4
5
  export type ColorValue = ThemeColor | `#${string}`;
@@ -20,6 +21,7 @@ export type SemanticColor =
20
21
  | "contextError"
21
22
  | "cost"
22
23
  | "tokens"
24
+ | "queue"
23
25
  | "separator"
24
26
  | "border";
25
27
 
@@ -27,29 +29,30 @@ export type SemanticColor =
27
29
  export type ColorScheme = Partial<Record<SemanticColor, ColorValue>>;
28
30
 
29
31
  // Built-in segment identifiers
30
- export type BuiltinStatusLineSegmentId =
31
- | "model"
32
- | "identity_group"
33
- | "shell_mode"
34
- | "path"
35
- | "git"
36
- | "subagents"
37
- | "token_in"
38
- | "token_out"
39
- | "token_total"
40
- | "usage_group"
41
- | "cost"
42
- | "context_pct"
43
- | "context_total"
44
- | "time_spent"
45
- | "time"
46
- | "session"
47
- | "session_name"
48
- | "hostname"
49
- | "cache_read"
50
- | "cache_write"
51
- | "thinking"
52
- | "extension_statuses";
32
+ export const BUILTIN_STATUS_LINE_SEGMENT_IDS = [
33
+ "model",
34
+ "shell_mode",
35
+ "path",
36
+ "git",
37
+ "subagents",
38
+ "queue",
39
+ "token_in",
40
+ "token_out",
41
+ "token_total",
42
+ "cost",
43
+ "context_pct",
44
+ "context_total",
45
+ "time_spent",
46
+ "time",
47
+ "session",
48
+ "hostname",
49
+ "cache_read",
50
+ "cache_write",
51
+ "thinking",
52
+ "extension_statuses",
53
+ ] as const;
54
+
55
+ export type BuiltinStatusLineSegmentId = typeof BUILTIN_STATUS_LINE_SEGMENT_IDS[number];
53
56
 
54
57
  // Segment identifiers (built-in + dynamically registered custom items)
55
58
  export type StatusLineSegmentId = BuiltinStatusLineSegmentId | `custom:${string}`;
@@ -68,28 +71,47 @@ export type StatusLineSeparatorStyle =
68
71
  | "star";
69
72
 
70
73
  // Preset names
74
+ export type PowerlinePlacement = "above" | "below";
75
+
71
76
  export type StatusLinePreset =
72
77
  | "default"
73
78
  | "minimal"
74
79
  | "compact"
75
80
  | "full"
76
81
  | "nerd"
77
- | "ascii"
78
- | "custom";
82
+ | "ascii";
79
83
 
80
84
  // Per-segment options
81
85
  export interface StatusLineSegmentOptions {
82
- model?: { showThinkingLevel?: boolean };
86
+ model?: { showThinkingLevel?: boolean; display?: "name" | "qualified" };
83
87
  path?: {
84
88
  mode?: "basename" | "abbreviated" | "full";
85
89
  maxLength?: number;
86
90
  };
87
- git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
91
+ git?: {
92
+ showBranch?: boolean;
93
+ showStaged?: boolean;
94
+ showUnstaged?: boolean;
95
+ showUntracked?: boolean;
96
+ polling?: "full" | "branch" | "off";
97
+ /** Replace the branch icon with the origin remote's host logo
98
+ * (GitHub/GitLab/Bitbucket, or a generic git logo). Default false. */
99
+ hostIcon?: boolean;
100
+ };
88
101
  time?: { format?: "12h" | "24h"; showSeconds?: boolean };
102
+ cost?: { subscriptionDisplay?: "subscription" | "reported-cost" | "both"; currency?: CostCurrencyCode };
103
+ context?: { format?: "full" | "percent" };
104
+ cache_read?: { format?: "tokens" | "percent" | "both" };
89
105
  }
90
106
 
91
107
  export type CustomItemPosition = "left" | "right" | "secondary";
92
108
 
109
+ export interface StatusLineLayout {
110
+ left?: StatusLineSegmentId[];
111
+ right?: StatusLineSegmentId[];
112
+ secondary?: StatusLineSegmentId[];
113
+ }
114
+
93
115
  export interface CustomStatusItem {
94
116
  id: string;
95
117
  statusKey: string;
@@ -132,18 +154,38 @@ export interface GitStatus {
132
154
  }
133
155
 
134
156
  // Usage statistics
157
+ export interface QueueSummary {
158
+ queueCount: number;
159
+ ideaCount: number;
160
+ blockedCount: number;
161
+ compacting: boolean;
162
+ leadingText: string | null;
163
+ leadingIntent: "steer" | "follow-up" | "post-compact" | "idea" | null;
164
+ leadingStatus: "queued" | "blocked" | "delivering" | "sent" | "failed" | null;
165
+ }
166
+
135
167
  export interface UsageStats {
136
168
  input: number;
137
169
  output: number;
138
170
  cacheRead: number;
139
171
  cacheWrite: number;
140
172
  cost: number;
173
+ // Cumulative cost of subagent child runs (e.g. /parallel, /worker) launched from this session.
174
+ subagentCost: number;
141
175
  }
142
176
 
143
177
  // Context passed to segment render functions
144
178
  export interface SegmentContext {
145
179
  // From pi-mono
146
- model: { id: string; name?: string; reasoning?: boolean; contextWindow?: number } | undefined;
180
+ model: {
181
+ id: string;
182
+ name?: string;
183
+ provider?: string;
184
+ providerId?: string;
185
+ providerName?: string;
186
+ reasoning?: boolean;
187
+ contextWindow?: number;
188
+ } | undefined;
147
189
  thinkingLevel: string;
148
190
  sessionId: string | undefined;
149
191
  sessionName: string | undefined;
@@ -151,11 +193,14 @@ export interface SegmentContext {
151
193
 
152
194
  // Computed
153
195
  usageStats: UsageStats;
154
- contextPercent: number;
196
+ contextTokens: number | null;
197
+ contextPercent: number | null;
155
198
  contextWindow: number;
199
+ contextApproximate: boolean;
156
200
  autoCompactEnabled: boolean;
157
201
  customCompactionEnabled: boolean;
158
202
  usingSubscription: boolean;
203
+ queueSummary: QueueSummary;
159
204
  sessionStartTime: number;
160
205
  shellModeActive: boolean;
161
206
  shellRunning: boolean;
@@ -0,0 +1,34 @@
1
+ export interface WelcomeDismissScheduler<Context> {
2
+ schedule(ctx: Context): void;
3
+ cancel(): void;
4
+ }
5
+
6
+ interface WelcomeDismissSchedulerOptions<Context> {
7
+ dismiss(ctx: Context): void;
8
+ getGeneration(): number;
9
+ isEnabled(): boolean;
10
+ }
11
+
12
+ export function createWelcomeDismissScheduler<Context>(
13
+ options: WelcomeDismissSchedulerOptions<Context>,
14
+ ): WelcomeDismissScheduler<Context> {
15
+ let timer: ReturnType<typeof setTimeout> | null = null;
16
+
17
+ return {
18
+ schedule(ctx) {
19
+ if (timer) return;
20
+
21
+ const generation = options.getGeneration();
22
+ timer = setTimeout(() => {
23
+ timer = null;
24
+ if (!options.isEnabled() || generation !== options.getGeneration()) return;
25
+ options.dismiss(ctx);
26
+ }, 0);
27
+ },
28
+ cancel() {
29
+ if (!timer) return;
30
+ clearTimeout(timer);
31
+ timer = null;
32
+ },
33
+ };
34
+ }
@@ -1,4 +1,4 @@
1
- import { readdirSync, existsSync, statSync, readFileSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, basename } from "node:path";
3
3
  import { homedir as osHomedir } from "node:os";
4
4
  import { CONFIG_DIR_NAME, getAgentDir, getSettingsPath } from "@selesai/code";
@@ -19,6 +19,13 @@ export interface LoadedCounts {
19
19
  promptTemplates: number;
20
20
  }
21
21
 
22
+ function formatTokens(tokens: number): string {
23
+ if (tokens < 1000) return tokens.toString();
24
+ if (tokens < 10000) return `${(tokens / 1000).toFixed(1)}k`;
25
+ if (tokens < 1000000) return `${Math.round(tokens / 1000)}k`;
26
+ return `${(tokens / 1000000).toFixed(tokens < 10000000 ? 1 : 0)}M`;
27
+ }
28
+
22
29
  // ═══════════════════════════════════════════════════════════════════════════
23
30
  // Shared rendering utilities
24
31
  // ═══════════════════════════════════════════════════════════════════════════
@@ -41,6 +48,7 @@ const GRADIENT_COLORS = [
41
48
  "\x1b[38;5;75m",
42
49
  "\x1b[38;5;51m",
43
50
  ];
51
+ const SESSION_HEADER_READ_BYTES = 8192;
44
52
 
45
53
  function bold(text: string): string {
46
54
  return `\x1b[1m${text}\x1b[22m`;
@@ -97,6 +105,7 @@ interface WelcomeData {
97
105
  loadedCounts: LoadedCounts;
98
106
  guideMode: GuideMode;
99
107
  newFeatures: GuideFeature[];
108
+ initialContextTokens: number | null;
100
109
  }
101
110
 
102
111
  function buildLeftColumn(data: WelcomeData, colWidth: number): string[] {
@@ -172,6 +181,14 @@ function buildRightColumn(data: WelcomeData, colWidth: number): string[] {
172
181
  countLines.push(` ${dim("No extensions loaded")}`);
173
182
  }
174
183
 
184
+ if (
185
+ data.initialContextTokens !== null
186
+ && Number.isFinite(data.initialContextTokens)
187
+ && data.initialContextTokens > 0
188
+ ) {
189
+ countLines.push(` ${itemPrefix}${fgOnly("gitClean", `≈ ${formatTokens(data.initialContextTokens)}`)} initial prompt tokens`);
190
+ }
191
+
175
192
  const dashboardLines = data.guideMode === "full"
176
193
  ? []
177
194
  : [
@@ -182,7 +199,6 @@ function buildRightColumn(data: WelcomeData, colWidth: number): string[] {
182
199
  ` ${bold(fgOnly("accent", "Recent sessions"))}`,
183
200
  ...sessionLines,
184
201
  ];
185
-
186
202
  return [
187
203
  ` ${bold(fgOnly("accent", "Tips"))}: ${dim("/")} commands · ${dim("/settings")} configure`,
188
204
  ` ${dim("Shift+Tab")} cycle thinking`,
@@ -266,8 +282,9 @@ export class WelcomeComponent implements Component {
266
282
  loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
267
283
  guideMode: GuideMode = "full",
268
284
  newFeatures: GuideFeature[] = [],
285
+ initialContextTokens: number | null = null,
269
286
  ) {
270
- this.data = { modelName, providerName, recentSessions, loadedCounts, guideMode, newFeatures };
287
+ this.data = { modelName, providerName, recentSessions, loadedCounts, guideMode, newFeatures, initialContextTokens };
271
288
  }
272
289
 
273
290
  invalidate(): void {}
@@ -313,8 +330,9 @@ export class WelcomeHeader implements Component {
313
330
  loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
314
331
  guideMode: GuideMode = "compact",
315
332
  newFeatures: GuideFeature[] = [],
333
+ initialContextTokens: number | null = null,
316
334
  ) {
317
- this.data = { modelName, providerName, recentSessions, loadedCounts, guideMode, newFeatures };
335
+ this.data = { modelName, providerName, recentSessions, loadedCounts, guideMode, newFeatures, initialContextTokens };
318
336
  }
319
337
 
320
338
  invalidate(): void {}
@@ -352,6 +370,15 @@ export class WelcomeHeader implements Component {
352
370
  const loggedDiscoveryErrors = new Set<string>();
353
371
 
354
372
  function logDiscoveryError(scope: string, error: unknown): void {
373
+ if (
374
+ typeof error === "object" &&
375
+ error !== null &&
376
+ "code" in error &&
377
+ (error as { code?: unknown }).code === "ENOENT"
378
+ ) {
379
+ return;
380
+ }
381
+
355
382
  const message = error instanceof Error ? error.message : String(error);
356
383
  const key = `${scope}:${message}`;
357
384
  if (loggedDiscoveryErrors.has(key)) {
@@ -578,6 +605,39 @@ export function discoverLoadedCounts(): LoadedCounts {
578
605
  /**
579
606
  * Get recent sessions from the sessions directory.
580
607
  */
608
+ function readSessionHeaderProjectName(filePath: string): string | null {
609
+ let fd: number | null = null;
610
+ try {
611
+ fd = openSync(filePath, "r");
612
+ const buffer = Buffer.alloc(SESSION_HEADER_READ_BYTES);
613
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
614
+ const firstLine = buffer.toString("utf8", 0, bytesRead).split(/\r?\n/, 1)[0]?.trim();
615
+ if (!firstLine) return null;
616
+
617
+ const header: unknown = JSON.parse(firstLine);
618
+ if (typeof header !== "object" || header === null || Array.isArray(header)) return null;
619
+
620
+ const cwd = Reflect.get(header, "cwd");
621
+ if (typeof cwd !== "string" || cwd.trim().length === 0) return null;
622
+
623
+ return basename(cwd) || cwd;
624
+ } catch {
625
+ return null;
626
+ } finally {
627
+ if (fd !== null) closeSync(fd);
628
+ }
629
+ }
630
+
631
+ function sessionProjectNameFromDirectory(dir: string): string {
632
+ const parentName = basename(dir);
633
+ if (!parentName.startsWith("--")) {
634
+ return parentName;
635
+ }
636
+
637
+ const parts = parentName.split("-").filter(p => p);
638
+ return parts[parts.length - 1] || parentName;
639
+ }
640
+
581
641
  export function getRecentSessions(maxCount: number = 3): RecentSession[] {
582
642
  const homeDir = getAgentDir();
583
643
  const home = homedirSafe();
@@ -600,12 +660,7 @@ export function getRecentSessions(maxCount: number = 3): RecentSession[] {
600
660
  if (stats.isDirectory()) {
601
661
  scanDir(entryPath);
602
662
  } else if (entry.endsWith(".jsonl")) {
603
- const parentName = basename(dir);
604
- let projectName = parentName;
605
- if (parentName.startsWith("--")) {
606
- const parts = parentName.split("-").filter(p => p);
607
- projectName = parts[parts.length - 1] || parentName;
608
- }
663
+ const projectName = readSessionHeaderProjectName(entryPath) ?? sessionProjectNameFromDirectory(dir);
609
664
  sessions.push({ name: projectName, mtime: stats.mtimeMs });
610
665
  }
611
666
  } catch (error) {