@xingular/harness 1.3.1-unstable.2 → 1.3.1-unstable.3

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 (76) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +1217 -701
  4. package/dist/sdk/computer/screenshot.d.ts +32 -0
  5. package/dist/sdk/computer/screenshot.js +35 -0
  6. package/dist/sdk/context/attribution.d.ts +18 -0
  7. package/dist/sdk/context/attribution.js +295 -0
  8. package/dist/sdk/context/types.d.ts +94 -0
  9. package/dist/sdk/context/types.js +10 -0
  10. package/dist/sdk/index.d.ts +2 -1
  11. package/dist/sdk/index.js +2 -1
  12. package/dist/sdk/plugins/builtin.js +1 -0
  13. package/dist/sdk/prompt/compile.d.ts +42 -2
  14. package/dist/sdk/prompt/compile.js +87 -15
  15. package/dist/sdk/server/methods.js +3 -0
  16. package/dist/sdk/sessions/contracts.d.ts +4 -0
  17. package/dist/sdk/sessions/contracts.js +2 -0
  18. package/dist/sdk/sessions/runner.d.ts +10 -0
  19. package/dist/sdk/sessions/runner.js +17 -0
  20. package/dist/sdk/sessions/subagentService.d.ts +11 -1
  21. package/dist/sdk/sessions/subagentService.js +120 -38
  22. package/dist/sdk/sessions/subagents.d.ts +31 -0
  23. package/dist/sdk/sessions/subagents.js +45 -0
  24. package/dist/sdk/terminal/client.d.ts +1 -1
  25. package/dist/sdk/terminal/client.js +13 -6
  26. package/dist/sdk/terminal/control.d.ts +1 -1
  27. package/dist/sdk/terminal/control.js +2 -2
  28. package/dist/sdk/terminal/controller.d.ts +9 -0
  29. package/dist/sdk/terminal/controller.js +48 -11
  30. package/dist/sdk/terminal/piExtension.js +35 -1
  31. package/dist/sdk/terminal/terminals.d.ts +2 -0
  32. package/dist/sdk/terminal/terminals.js +11 -0
  33. package/dist/sdk/terminal/tui/commands.d.ts +1 -2
  34. package/dist/sdk/terminal/tui/commands.js +1 -4
  35. package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
  36. package/dist/sdk/terminal/tui/dialogs.js +4 -3
  37. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  38. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  39. package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
  40. package/dist/sdk/terminal/tui/modeChoices.js +4 -4
  41. package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
  42. package/dist/sdk/terminal/tui/mouseReport.js +80 -0
  43. package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
  44. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
  45. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +54 -0
  46. package/dist/sdk/terminal/tui/pages/contextPage.js +393 -0
  47. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
  48. package/dist/sdk/terminal/tui/runtime.js +8 -2
  49. package/dist/sdk/terminal/tui/search.js +3 -2
  50. package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
  51. package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
  52. package/dist/sdk/tools/defaultSkills.d.ts +19 -0
  53. package/dist/sdk/tools/defaultSkills.js +27 -1
  54. package/dist/sdk/tools/dispatch.js +8 -9
  55. package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
  56. package/dist/sdk/tools/spawnSubagent.js +4 -2
  57. package/dist/terminal-opentui.js +570 -240
  58. package/dist/terminal-pi.js +142 -28
  59. package/docs/README.md +1 -0
  60. package/docs/architecture/02-sessions-and-runs.md +9 -0
  61. package/docs/architecture/04-prompt.md +13 -4
  62. package/docs/architecture/07-tools-and-gate.md +1 -1
  63. package/docs/architecture/11-terminal.md +12 -2
  64. package/docs/architecture/13-world-tools.md +17 -3
  65. package/docs/architecture/14-surfaces-and-parity.md +3 -3
  66. package/docs/architecture/README.md +1 -0
  67. package/docs/architecture/proposals/PROP-0049-context-attribution.md +382 -0
  68. package/docs/architecture/proposals/README.md +1 -0
  69. package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
  70. package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
  71. package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
  72. package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
  73. package/docs/parity.md +6 -6
  74. package/docs/releases/1.3.1-unstable.3.md +54 -0
  75. package/docs/terminal-tui.md +28 -15
  76. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent';
2
+ import { type TabInfo } from "../chrome.js";
3
+ import type { TerminalPage } from "../page.js";
4
+ export type ContextTab = 'request' | 'growth';
5
+ export type ContextGrouping = 'owner' | 'kind';
6
+ interface ContextPageOptions {
7
+ theme(): Theme;
8
+ call(method: string, params?: Record<string, unknown>): Promise<unknown>;
9
+ changed(): void;
10
+ initialTab?: ContextTab;
11
+ /** Opens the variant editor for the row the person wants to shrink. */
12
+ openVariant?(): void;
13
+ /** Runs the session's compaction, after the page confirms what it would free. */
14
+ compact?(): Promise<void>;
15
+ confirm?(title: string, message: string): Promise<boolean>;
16
+ }
17
+ export declare class ContextPage implements TerminalPage {
18
+ private readonly options;
19
+ presentation: "fullscreen";
20
+ focused: boolean;
21
+ private tab;
22
+ private grouping;
23
+ private data;
24
+ private detail;
25
+ private selectedId;
26
+ private folded;
27
+ private offset;
28
+ private detailOffset;
29
+ private maxOffset;
30
+ private scrollRows;
31
+ private busy;
32
+ private reloadPending;
33
+ private error;
34
+ private disposed;
35
+ constructor(options: ContextPageOptions);
36
+ tabs(): readonly TabInfo[];
37
+ activeTab(): string;
38
+ selectTab(id: string): void;
39
+ hint(): string;
40
+ activate(): void;
41
+ dispose(): void;
42
+ private load;
43
+ handleInput(data: string): boolean;
44
+ private move;
45
+ private compact;
46
+ /** The rows in the order the page draws them, with the heading each one introduces. */
47
+ private entries;
48
+ render(width: number, rows: number): string[];
49
+ private requestBody;
50
+ private rowLine;
51
+ private growthBody;
52
+ private detailBody;
53
+ }
54
+ export {};
@@ -0,0 +1,393 @@
1
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
+ import { CHROME_PAD, headerRow, neutralRule, padded, selectedRow, selectionMuted, selectionText } from "../chrome.js";
3
+ const BARS = '▁▂▃▄▅▆▇█';
4
+ const OWNER_TITLES = {
5
+ foundation: 'Foundation · harness',
6
+ variant: 'Variant',
7
+ application: 'Your application',
8
+ conversation: 'Conversation',
9
+ reserved: 'Reserved and free'
10
+ };
11
+ const OWNER_ORDER = ['foundation', 'variant', 'application', 'conversation', 'reserved'];
12
+ const KIND_ORDER = ['System prompt', 'Tools', 'Memory', 'Skills', 'Messages', 'Reserved'];
13
+ function kindOf(row) {
14
+ if (row.group === 'reserved')
15
+ return 'Reserved';
16
+ if (row.id === 'messages' || row.id === 'context-update' || row.id === 'references' || row.id === 'required-skills' || row.id === 'wiki' || row.id === 'session-state')
17
+ return 'Messages';
18
+ if (row.id === 'memory')
19
+ return 'Memory';
20
+ if (row.id === 'skills')
21
+ return 'Skills';
22
+ if (row.id === 'tools' || row.id === 'tools-harness' || row.id === 'tools-application' || row.id === 'tool-guidance')
23
+ return 'Tools';
24
+ return 'System prompt';
25
+ }
26
+ function count(value) {
27
+ if (!Number.isFinite(value) || value <= 0)
28
+ return '0';
29
+ if (value >= 1_000_000)
30
+ return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
31
+ if (value >= 1_000)
32
+ return `${(value / 1_000).toFixed(1)}K`;
33
+ return String(Math.round(value));
34
+ }
35
+ function percent(value) {
36
+ return value === null ? '?' : `${value.toFixed(1)}%`;
37
+ }
38
+ function tone(share) {
39
+ if (share === null)
40
+ return 'dim';
41
+ if (share >= 90)
42
+ return 'error';
43
+ if (share >= 70)
44
+ return 'warning';
45
+ return 'success';
46
+ }
47
+ /** A context bar: filled as the window is used, with room left in the track. */
48
+ function usageBar(share, width, theme) {
49
+ const room = Math.max(1, width - 2);
50
+ const track = '░'.repeat(room);
51
+ if (share === null)
52
+ return `[${theme.fg('dim', track)}]`;
53
+ const filled = Math.max(0, Math.min(room, Math.round((Math.max(0, Math.min(100, share)) / 100) * room)));
54
+ return `[${theme.fg(tone(share), '█'.repeat(filled))}${theme.fg('dim', '░'.repeat(room - filled))}]`;
55
+ }
56
+ /** A label on the left and a quiet value at the right edge of the same row. */
57
+ function spread(left, right, width) {
58
+ if (!right)
59
+ return truncateToWidth(left, width, '');
60
+ const room = Math.max(0, width - visibleWidth(left) - 2);
61
+ const value = truncateToWidth(right, room, '');
62
+ return left + ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(value))) + value;
63
+ }
64
+ export class ContextPage {
65
+ options;
66
+ presentation = 'fullscreen';
67
+ focused = false;
68
+ tab;
69
+ grouping = 'owner';
70
+ data = null;
71
+ detail = null;
72
+ selectedId = null;
73
+ folded = new Set();
74
+ offset = 0;
75
+ detailOffset = 0;
76
+ maxOffset = 0;
77
+ scrollRows = 1;
78
+ busy = false;
79
+ reloadPending = false;
80
+ error = null;
81
+ disposed = false;
82
+ constructor(options) {
83
+ this.options = options;
84
+ this.tab = options.initialTab ?? 'request';
85
+ }
86
+ tabs() {
87
+ return [{ id: 'request', title: 'Request' }, { id: 'growth', title: 'Growth' }];
88
+ }
89
+ activeTab() {
90
+ return this.tab;
91
+ }
92
+ selectTab(id) {
93
+ if (id !== 'request' && id !== 'growth')
94
+ return;
95
+ if (id === this.tab)
96
+ return;
97
+ this.tab = id;
98
+ this.offset = 0;
99
+ this.detail = null;
100
+ this.options.changed();
101
+ if (!this.data)
102
+ void this.load();
103
+ }
104
+ hint() {
105
+ if (this.detail)
106
+ return '↑/↓ scroll · esc close · q close page';
107
+ return this.tab === 'request'
108
+ ? '↑/↓ move · space fold · enter text · g group · c compact · v variant · r refresh · q close'
109
+ : '↑/↓ scroll · r refresh · q close';
110
+ }
111
+ activate() {
112
+ if (!this.data)
113
+ void this.load();
114
+ }
115
+ dispose() {
116
+ this.disposed = true;
117
+ }
118
+ async load(section) {
119
+ if (this.disposed)
120
+ return;
121
+ if (this.busy) {
122
+ this.reloadPending = true;
123
+ return;
124
+ }
125
+ this.busy = true;
126
+ this.error = null;
127
+ this.options.changed();
128
+ try {
129
+ const answer = await this.options.call('terminal.context', section ? { section } : {});
130
+ if (this.disposed)
131
+ return;
132
+ this.data = answer.attribution ?? null;
133
+ if (section) {
134
+ if (answer.text)
135
+ this.detail = answer.text;
136
+ else
137
+ this.error = 'That row carries no text: it is measured, not written.';
138
+ this.detailOffset = 0;
139
+ }
140
+ }
141
+ catch (cause) {
142
+ this.error = cause instanceof Error ? cause.message : 'The context could not be read.';
143
+ }
144
+ finally {
145
+ this.busy = false;
146
+ this.options.changed();
147
+ if (this.reloadPending) {
148
+ this.reloadPending = false;
149
+ void this.load();
150
+ }
151
+ }
152
+ }
153
+ handleInput(data) {
154
+ if (this.detail) {
155
+ const page = Math.max(1, this.scrollRows - 2);
156
+ if (matchesKey(data, 'up'))
157
+ this.detailOffset = Math.max(0, this.detailOffset - 1);
158
+ else if (matchesKey(data, 'down'))
159
+ this.detailOffset += 1;
160
+ else if (matchesKey(data, 'pageUp'))
161
+ this.detailOffset = Math.max(0, this.detailOffset - page);
162
+ else if (matchesKey(data, 'pageDown'))
163
+ this.detailOffset += page;
164
+ else if (matchesKey(data, 'escape') || data === 'q') {
165
+ this.detail = null;
166
+ this.options.changed();
167
+ }
168
+ else
169
+ return false;
170
+ this.options.changed();
171
+ return true;
172
+ }
173
+ if (matchesKey(data, 'tab') || matchesKey(data, 'shift+tab')) {
174
+ const tabs = ['request', 'growth'];
175
+ const direction = matchesKey(data, 'shift+tab') ? -1 : 1;
176
+ this.selectTab(tabs[(tabs.indexOf(this.tab) + direction + tabs.length) % tabs.length]);
177
+ return true;
178
+ }
179
+ const entries = this.entries();
180
+ const current = Math.max(0, entries.findIndex((entry) => entry.row.id === this.selectedId));
181
+ if (matchesKey(data, 'up'))
182
+ this.move(entries, current - 1);
183
+ else if (matchesKey(data, 'down'))
184
+ this.move(entries, current + 1);
185
+ else if (matchesKey(data, 'home'))
186
+ this.offset = 0;
187
+ else if (matchesKey(data, 'end'))
188
+ this.offset = this.maxOffset;
189
+ else if (data === ' ' && entries[current]) {
190
+ const row = entries[current].row;
191
+ if (this.folded.has(row.group))
192
+ this.folded.delete(row.group);
193
+ else
194
+ this.folded.add(row.group);
195
+ }
196
+ else if (matchesKey(data, 'enter') && entries[current]) {
197
+ void this.load(entries[current].row.id);
198
+ return true;
199
+ }
200
+ else if (data === 'g') {
201
+ this.grouping = this.grouping === 'owner' ? 'kind' : 'owner';
202
+ this.folded.clear();
203
+ this.offset = 0;
204
+ }
205
+ else if (data === 'r') {
206
+ void this.load();
207
+ return true;
208
+ }
209
+ else if (data === 'c') {
210
+ void this.compact();
211
+ return true;
212
+ }
213
+ else if (data === 'v') {
214
+ this.options.openVariant?.();
215
+ return true;
216
+ }
217
+ else
218
+ return false;
219
+ this.options.changed();
220
+ return true;
221
+ }
222
+ move(entries, index) {
223
+ if (!entries.length)
224
+ return;
225
+ const next = entries[Math.max(0, Math.min(entries.length - 1, index))];
226
+ if (next)
227
+ this.selectedId = next.row.id;
228
+ }
229
+ async compact() {
230
+ if (!this.options.compact)
231
+ return;
232
+ const free = this.data?.free === null || this.data?.free === undefined ? null : this.data.free;
233
+ const question = free === null
234
+ ? 'Pi will summarize this conversation and start the next request from the summary.'
235
+ : `Pi will summarize this conversation. About ${count(free)} tokens are free before it does.`;
236
+ if (this.options.confirm && !(await this.options.confirm('Compact the conversation now?', question)))
237
+ return;
238
+ try {
239
+ await this.options.compact();
240
+ await this.load();
241
+ }
242
+ catch (cause) {
243
+ this.error = cause instanceof Error ? cause.message : 'Compaction failed.';
244
+ this.options.changed();
245
+ }
246
+ }
247
+ /** The rows in the order the page draws them, with the heading each one introduces. */
248
+ entries() {
249
+ const rows = this.data?.rows ?? [];
250
+ if (this.grouping === 'owner') {
251
+ const ordered = [...rows].sort((left, right) => OWNER_ORDER.indexOf(left.group) - OWNER_ORDER.indexOf(right.group));
252
+ const out = [];
253
+ for (const row of ordered) {
254
+ const previous = out.at(-1)?.row.group;
255
+ out.push({ row, heading: previous === row.group ? null : (OWNER_TITLES[row.group] ?? row.group), folded: this.folded.has(row.group) });
256
+ }
257
+ return out;
258
+ }
259
+ const ordered = [...rows].sort((left, right) => KIND_ORDER.indexOf(kindOf(left)) - KIND_ORDER.indexOf(kindOf(right)));
260
+ const out = [];
261
+ let last = null;
262
+ for (const row of ordered) {
263
+ const kind = kindOf(row);
264
+ const folded = this.folded.has(kind);
265
+ out.push({ row, heading: last === kind ? null : kind, folded });
266
+ last = kind;
267
+ }
268
+ return out;
269
+ }
270
+ render(width, rows) {
271
+ if (width < 1 || rows < 1)
272
+ return [];
273
+ const theme = this.options.theme();
274
+ const data = this.data;
275
+ const meta = data ? `${count(data.used)} / ${data.window === null ? '?' : count(data.window)} · ${percent(data.share)}` : undefined;
276
+ const header = [headerRow('Context', meta, width, theme), neutralRule(width, theme)];
277
+ const body = this.detail ? this.detailBody(width, theme) : this.tab === 'growth' ? this.growthBody(width, theme) : this.requestBody(width, theme);
278
+ const list = this.detail ? body : body;
279
+ const room = Math.max(0, rows - header.length - 2);
280
+ this.scrollRows = Math.max(1, room);
281
+ const offset = this.detail ? Math.min(this.detailOffset, Math.max(0, list.length - room)) : Math.min(this.offset, Math.max(0, list.length - room));
282
+ this.maxOffset = Math.max(0, list.length - room);
283
+ if (this.detail)
284
+ this.detailOffset = offset;
285
+ else
286
+ this.offset = offset;
287
+ const footer = [neutralRule(width, theme), padded(theme.fg('dim', this.hint()), width)];
288
+ return [...header, ...list.slice(offset, offset + room), ...footer].map((line) => truncateToWidth(line, width, ''));
289
+ }
290
+ requestBody(width, theme) {
291
+ const data = this.data;
292
+ if (!data) {
293
+ return [padded(theme.fg(this.error ? 'error' : 'dim', this.error ?? (this.busy ? 'Reading the recorded context…' : 'No reply yet: the next request records what it is made of.')), width)];
294
+ }
295
+ const inner = Math.max(8, width - CHROME_PAD * 2);
296
+ const body = [];
297
+ body.push(padded(spread(theme.fg('text', `${count(data.used)} of ${data.window === null ? 'an unknown window' : count(data.window)}`), theme.fg(tone(data.share), `${percent(data.share)} used · ${data.source}`), inner), width));
298
+ body.push(padded(usageBar(data.share, inner, theme), width));
299
+ body.push('');
300
+ for (const entry of this.entries()) {
301
+ if (entry.heading) {
302
+ const total = data.rows.filter((row) => (this.grouping === 'owner' ? OWNER_TITLES[row.group] === entry.heading : kindOf(row) === entry.heading)).reduce((sum, row) => sum + row.tokens, 0);
303
+ body.push(padded(theme.bold(theme.fg('accent', entry.heading)) + theme.fg('dim', ` ${count(total)}`), width));
304
+ }
305
+ if (entry.folded)
306
+ continue;
307
+ body.push(this.rowLine(entry.row, inner, width, theme));
308
+ }
309
+ body.push('');
310
+ body.push(padded(theme.fg('dim', `free ${data.free === null ? '?' : count(data.free)}${data.compaction ? ` · reserve ${count(data.compaction.reserveTokens)}` : ''}`), width));
311
+ if (data.onDemand.length) {
312
+ body.push('');
313
+ body.push(padded(theme.fg('accent', 'On demand') + theme.fg('dim', ' not in this request'), width));
314
+ for (const item of data.onDemand) {
315
+ const cost = item.tokensEach === null ? (item.note ?? '') : `about ${count(item.tokensEach)} each · ${item.note ?? ''}`;
316
+ body.push(padded(` ${theme.fg('text', item.title)} ${theme.fg('dim', `${item.items} available · ${cost}`)}`, width));
317
+ }
318
+ }
319
+ if (data.note) {
320
+ body.push('');
321
+ body.push(...wrapTextWithAnsi(theme.fg('dim', data.note), inner).map((line) => padded(line, width)));
322
+ }
323
+ if (this.error)
324
+ body.push(padded(theme.fg('error', this.error), width));
325
+ return body;
326
+ }
327
+ rowLine(row, inner, width, theme) {
328
+ const selected = row.id === this.selectedId;
329
+ const marker = selected ? '❯ ' : row.always ? ' ' : '· ';
330
+ const budget = row.budget ? ` ${count(row.tokens * 4)} of ${count(row.budget)} chars` : '';
331
+ const tools = row.tools?.length ? ` ${row.tools.map((tool) => `${tool.group} ${tool.count}`).join(' · ')}` : '';
332
+ const note = row.note ? ` ${row.note}` : '';
333
+ const tail = `${count(row.tokens).padStart(7)}${percent(row.share).padStart(7)}`;
334
+ const label = `${marker}${row.title}`;
335
+ const fixed = visibleWidth(tail) + 1;
336
+ const left = truncateToWidth(label, Math.max(4, inner - fixed - visibleWidth(budget) - visibleWidth(tools) - visibleWidth(note)), '…');
337
+ const line = `${left}${' '.repeat(Math.max(1, inner - visibleWidth(left) - fixed))}${tail}`;
338
+ if (selected)
339
+ return selectedRow(padded(line, width), width, theme);
340
+ const rest = `${budget}${tools}${note}`;
341
+ if (rest && inner > visibleWidth(line) + visibleWidth(rest)) {
342
+ const quiet = theme.fg('dim', rest);
343
+ return padded(selected ? selectionMuted(theme, line) : line + ' '.repeat(Math.max(1, inner - visibleWidth(line) - visibleWidth(rest))) + quiet, width);
344
+ }
345
+ return padded(selected ? selectionText(theme, line) : line, width);
346
+ }
347
+ growthBody(width, theme) {
348
+ const data = this.data;
349
+ if (!data)
350
+ return [padded(theme.fg('dim', this.busy ? 'Reading the recorded context…' : 'No reply yet.'), width)];
351
+ const inner = Math.max(8, width - CHROME_PAD * 2);
352
+ const body = [];
353
+ const history = data.history.slice(-24);
354
+ if (history.length) {
355
+ const peak = Math.max(...history.map((point) => point.used), 1);
356
+ const spark = history.map((point) => BARS[Math.min(BARS.length - 1, Math.round((point.used / peak) * (BARS.length - 1)))]).join('');
357
+ body.push(padded(theme.fg('accent', spark), width));
358
+ body.push(padded(theme.fg('dim', `${history.length} requests · peak ${count(peak)}${data.compactions.length ? ` · ${data.compactions.length} compaction${data.compactions.length === 1 ? '' : 's'}` : ''}`), width));
359
+ }
360
+ else {
361
+ body.push(padded(theme.fg('dim', 'No measured request yet.'), width));
362
+ }
363
+ body.push('');
364
+ body.push(padded(spread(theme.fg('dim', 'REQUEST'), theme.fg('dim', 'USED SHARE'), inner), width));
365
+ for (const point of history.slice().reverse().slice(0, 12)) {
366
+ body.push(padded(spread(` ${point.request}`, `${count(point.used).padStart(8)}${percent(point.share).padStart(8)}`, inner), width));
367
+ }
368
+ for (const compaction of data.compactions.slice(-3)) {
369
+ const before = compaction.before === null ? '?' : count(compaction.before);
370
+ const after = compaction.after === null ? '?' : count(compaction.after);
371
+ body.push(padded(theme.fg('warning', ` compacted ${before} → ${after}`), width));
372
+ }
373
+ body.push('');
374
+ body.push(...wrapTextWithAnsi(theme.fg('dim', data.note ?? ''), inner).filter(Boolean).map((line) => padded(line, width)));
375
+ return body;
376
+ }
377
+ detailBody(width, theme) {
378
+ const detail = this.detail;
379
+ const inner = Math.max(8, width - CHROME_PAD * 2);
380
+ const body = [];
381
+ body.push(padded(theme.bold(theme.fg('accent', detail.title)), width));
382
+ body.push('');
383
+ for (const line of detail.text.split('\n')) {
384
+ if (!line.trim()) {
385
+ body.push('');
386
+ continue;
387
+ }
388
+ for (const wrapped of wrapTextWithAnsi(theme.fg('text', line), inner))
389
+ body.push(padded(wrapped, width));
390
+ }
391
+ return body;
392
+ }
393
+ }
@@ -1,11 +1,12 @@
1
1
  import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { FilterList } from "../list.js";
3
+ import { stripMouseReports } from "../mouseReport.js";
3
4
  const ACTIVE = new Set(['queued', 'running', 'waiting_for_approval', 'waiting_for_input']);
4
5
  const payloadOf = (event) => event.payload && typeof event.payload === 'object' ? event.payload : {};
5
6
  const stringOf = (value) => typeof value === 'string' ? value : undefined;
6
7
  const numberOf = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
7
8
  function safe(value) {
8
- return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ');
9
+ return stripTerminalSequences(stripMouseReports(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ');
9
10
  }
10
11
  function timeOf(value) {
11
12
  const parsed = Date.parse(value);
@@ -220,8 +221,8 @@ export class TrajectoryPage {
220
221
  return this.events.map(event => ({
221
222
  id: String(event.sequence),
222
223
  label: `${timeOf(event.timestamp)} ${event.type}`,
223
- detail: eventDescription(event),
224
- searchText: `${event.timestamp} ${event.type} ${eventDescription(event)} ${safe(JSON.stringify(payloadOf(event)))}`,
224
+ detail: safe(eventDescription(event)),
225
+ searchText: `${event.timestamp} ${event.type} ${safe(eventDescription(event))} ${safe(JSON.stringify(payloadOf(event)))}`,
225
226
  data: event
226
227
  }));
227
228
  }
@@ -366,7 +367,7 @@ export class TrajectoryPage {
366
367
  if (rows >= 4)
367
368
  lines.push(theme.fg('muted', usageLine(this.events)));
368
369
  if (rows >= 6)
369
- lines.push(theme.fg('dim', this.run.goal ? `Prompt: ${this.run.goal.replace(/\s+/g, ' ').slice(0, 140)}` : 'Prompt text unavailable'));
370
+ lines.push(theme.fg('dim', this.run.goal ? `Prompt: ${safe(this.run.goal).replace(/\s+/g, ' ').slice(0, 140)}` : 'Prompt text unavailable'));
370
371
  if (this.error)
371
372
  lines.push(theme.fg('error', `Read error: ${this.error}`));
372
373
  return lines.slice(0, Math.min(rows, 5)).map(line => truncateToWidth(line, width, ''));
@@ -145,7 +145,13 @@ export async function createTerminalRuntime(args, host) {
145
145
  // A selection is recorded against the mode it was made in; a one-shot plan run is always plan.
146
146
  const recordingMode = () => (planRun.active ? 'plan' : currentMode);
147
147
  const modelFor = (reference) => resolveCliModel({ cliModel: reference, modelRuntime }).model;
148
- const applyMode = () => modeChoices.apply(native.session, currentMode, modelFor);
148
+ // The level Pi resolves when no mode has saved an effort: the model's own default, then the account's.
149
+ const defaultEffort = (session) => {
150
+ const model = session.model;
151
+ return ((model ? session.settingsManager.getModelThinkingLevel(model.provider, model.id) : undefined)
152
+ ?? session.settingsManager.getDefaultThinkingLevel() ?? 'medium');
153
+ };
154
+ const applyMode = () => modeChoices.apply(native.session, currentMode, modelFor, defaultEffort(native.session));
149
155
  const restoreChoice = async (session, previous) => {
150
156
  if (previous.model && (session.model?.provider !== previous.model.provider || session.model?.id !== previous.model.id)) {
151
157
  await session.setModel(previous.model, { persist: false }).catch(() => undefined);
@@ -325,7 +331,7 @@ export async function createTerminalRuntime(args, host) {
325
331
  planRun.active = true;
326
332
  try {
327
333
  session.setActiveToolsByName(allowedTools);
328
- await modeChoices.apply(session, 'plan', modelFor);
334
+ await modeChoices.apply(session, 'plan', modelFor, defaultEffort(session));
329
335
  await session.prompt(text, { expandPromptTemplates: false, ...(images?.length ? { images } : {}) });
330
336
  }
331
337
  finally {
@@ -1,4 +1,5 @@
1
- import { Input, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
1
+ import { matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
+ import { MouseSafeInput } from "./mouseReport.js";
2
3
  export const SEARCH_GLYPH = '⌕';
3
4
  export const SEARCH_PLACEHOLDER = 'Type to search';
4
5
  export const SEARCH_FIELD_ROWS = 5;
@@ -14,7 +15,7 @@ export class SearchField {
14
15
  input;
15
16
  constructor(theme, placeholder = SEARCH_PLACEHOLDER) {
16
17
  this.theme = theme;
17
- this.input = new Input({ prompt: '', placeholder, placeholderStyle: text => this.theme().fg('dim', text) });
18
+ this.input = new MouseSafeInput({ prompt: '', placeholder, placeholderStyle: text => this.theme().fg('dim', text) });
18
19
  }
19
20
  get value() { return this.input.getValue(); }
20
21
  set focused(value) { this.input.focused = value; }
@@ -3,7 +3,26 @@ export interface SkillEntry {
3
3
  name: string;
4
4
  description: string;
5
5
  }
6
- /** Rewrites a leading `$name` of a loaded skill into `/skill:name`. Anything else passes through, so `$5` stays text. */
7
- export declare function expandSkillInvocation(text: string, skills: ReadonlySet<string>): string;
6
+ export interface SkillToken {
7
+ /** What has been typed after the `$`. */
8
+ query: string;
9
+ /** Where the `$` is. */
10
+ from: number;
11
+ /** The caret. */
12
+ to: number;
13
+ }
14
+ export interface SkillInvocationMatch {
15
+ name: string;
16
+ /** Where the `$` is. */
17
+ from: number;
18
+ /** Just after the name. */
19
+ to: number;
20
+ }
21
+ /** The skill token being typed at `caret`, or null when the caret is not in one. */
22
+ export declare function skillTokenAt(text: string, caret: number): SkillToken | null;
23
+ /** Every `$name` token in the message, whether or not the name is a loaded skill. */
24
+ export declare function skillInvocationMatches(text: string): SkillInvocationMatch[];
25
+ /** Every `$name` in the message that names an enabled skill, in message order, once each. */
26
+ export declare function skillInvocations(text: string, skills: ReadonlySet<string>): string[];
8
27
  /** Offers the loaded skills at a `$` that starts a word. Every other completion is `base`'s. */
9
28
  export declare function skillCompletions(base: AutocompleteProvider, skills: () => readonly SkillEntry[]): AutocompleteProvider;
@@ -1,24 +1,44 @@
1
- /** Rewrites a leading `$name` of a loaded skill into `/skill:name`. Anything else passes through, so `$5` stays text. */
2
- export function expandSkillInvocation(text, skills) {
3
- const trimmed = text.trimStart();
4
- const match = /^\$(\S+)(?=\s|$)/.exec(trimmed);
5
- if (!match || !skills.has(match[1]))
6
- return text;
7
- return `/skill:${match[1]}${trimmed.slice(match[0].length)}`;
8
- }
9
1
  /** A `$` that starts a word, and the partial name after it, at the cursor. */
10
- const SKILL_TOKEN = /(?:^|\s)\$([^\s$]*)$/;
2
+ const SKILL_TOKEN = /(?:^|[\s(])\$([^\s$]*)$/;
3
+ /** A `$name` at a word start, ending at whitespace, punctuation or the end of the message. */
4
+ const SKILL_INVOCATION = /(?:^|[\s(])\$([A-Za-z0-9][A-Za-z0-9_-]*)(?=[\s.,;:!?)]|$)/gu;
11
5
  /** A line whose first word is a skill invocation. */
12
6
  const SKILL_LINE = /^\s*\$\S/;
7
+ /** The skill token being typed at `caret`, or null when the caret is not in one. */
8
+ export function skillTokenAt(text, caret) {
9
+ const match = SKILL_TOKEN.exec(text.slice(0, caret));
10
+ if (!match)
11
+ return null;
12
+ const query = match[1] ?? '';
13
+ return { query, from: caret - query.length - 1, to: caret };
14
+ }
15
+ /** Every `$name` token in the message, whether or not the name is a loaded skill. */
16
+ export function skillInvocationMatches(text) {
17
+ const found = [];
18
+ for (const match of text.matchAll(SKILL_INVOCATION)) {
19
+ const from = match.index + match[0].indexOf('$');
20
+ found.push({ name: match[1], from, to: from + 1 + match[1].length });
21
+ }
22
+ return found;
23
+ }
24
+ /** Every `$name` in the message that names an enabled skill, in message order, once each. */
25
+ export function skillInvocations(text, skills) {
26
+ const found = [];
27
+ for (const match of skillInvocationMatches(text)) {
28
+ if (skills.has(match.name) && !found.includes(match.name))
29
+ found.push(match.name);
30
+ }
31
+ return found;
32
+ }
13
33
  /** Offers the loaded skills at a `$` that starts a word. Every other completion is `base`'s. */
14
34
  export function skillCompletions(base, skills) {
15
35
  return {
16
36
  triggerCharacters: [...(base.triggerCharacters ?? []), '$'],
17
37
  async getSuggestions(lines, cursorLine, cursorCol, options) {
18
38
  const line = lines[cursorLine] ?? '';
19
- const token = SKILL_TOKEN.exec(line.slice(0, cursorCol));
39
+ const token = skillTokenAt(line, cursorCol);
20
40
  if (token) {
21
- const partial = token[1];
41
+ const partial = token.query;
22
42
  const items = skills()
23
43
  .filter((skill) => skill.name.startsWith(partial))
24
44
  .map((skill) => ({ value: skill.name, label: `$${skill.name}`, description: skill.description }));
@@ -1,4 +1,23 @@
1
+ /** A skill folder name, without `skills/` or backticks. */
2
+ export declare const SKILL_NAME: RegExp;
3
+ /** One skill's file: what `skill_load` reads, and what the prompt compiler includes. */
4
+ export interface SkillFile {
5
+ name: string;
6
+ /** The `SKILL.md` file the text came from. */
7
+ path: string;
8
+ /** The skill folder that relative references resolve against. */
9
+ baseDir: string;
10
+ /** The instructions after the frontmatter. */
11
+ body: string;
12
+ /** Files in the skill's `scripts/` folder, empty when it has none. */
13
+ helpers: string[];
14
+ }
1
15
  /** Locate release-owned resources from source, the SDK, or a bundled executable. */
2
16
  export declare function defaultSkillPath(): string;
3
17
  /** Variant skills win on a name collision, without modifying a pinned variant. */
4
18
  export declare function defaultSkillPaths(variantDir: string | null | undefined): string[];
19
+ /**
20
+ * A skill by folder name: the pinned variant first, then the shipped `plugin-authoring` skill.
21
+ * The body has its frontmatter removed. Returns null when no skill has that name.
22
+ */
23
+ export declare function readSkillFile(variantDir: string | null | undefined, name: string): SkillFile | null;
@@ -1,6 +1,9 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { bodyOf } from "../core/frontmatter.js";
5
+ /** A skill folder name, without `skills/` or backticks. */
6
+ export const SKILL_NAME = /^[a-z0-9][a-z0-9_-]*$/;
4
7
  /** Locate release-owned resources from source, the SDK, or a bundled executable. */
5
8
  export function defaultSkillPath() {
6
9
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -18,3 +21,26 @@ export function defaultSkillPath() {
18
21
  export function defaultSkillPaths(variantDir) {
19
22
  return variantDir && existsSync(join(variantDir, 'skills', 'plugin-authoring', 'SKILL.md')) ? [] : [defaultSkillPath()];
20
23
  }
24
+ /**
25
+ * A skill by folder name: the pinned variant first, then the shipped `plugin-authoring` skill.
26
+ * The body has its frontmatter removed. Returns null when no skill has that name.
27
+ */
28
+ export function readSkillFile(variantDir, name) {
29
+ const wanted = name.trim().replace(/^skills\//, '').replace(/`/g, '');
30
+ if (!SKILL_NAME.test(wanted))
31
+ return null;
32
+ const variant = variantDir ? join(variantDir, 'skills', wanted, 'SKILL.md') : null;
33
+ const builtin = wanted === 'plugin-authoring' && defaultSkillPaths(variantDir).length ? defaultSkillPath() : null;
34
+ const path = variant && existsSync(variant) && statSync(variant).isFile() ? variant : builtin;
35
+ if (!path)
36
+ return null;
37
+ const baseDir = dirname(path);
38
+ const scripts = join(baseDir, 'scripts');
39
+ return {
40
+ name: wanted,
41
+ path,
42
+ baseDir,
43
+ body: bodyOf(readFileSync(path, 'utf8')),
44
+ helpers: existsSync(scripts) ? readdirSync(scripts) : []
45
+ };
46
+ }