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

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 (77) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +2513 -1296
  3. package/dist/sdk/cli.js +2 -0
  4. package/dist/sdk/commands/registry.d.ts +15 -0
  5. package/dist/sdk/commands/registry.js +2 -0
  6. package/dist/sdk/remote/addresses.d.ts +19 -0
  7. package/dist/sdk/remote/addresses.js +52 -0
  8. package/dist/sdk/remote/pairing.d.ts +59 -0
  9. package/dist/sdk/remote/pairing.js +160 -0
  10. package/dist/sdk/remote/seal.d.ts +28 -0
  11. package/dist/sdk/remote/seal.js +84 -0
  12. package/dist/sdk/remote/server.d.ts +28 -0
  13. package/dist/sdk/remote/server.js +158 -0
  14. package/dist/sdk/remote/service.d.ts +125 -0
  15. package/dist/sdk/remote/service.js +330 -0
  16. package/dist/sdk/remote/webClient.d.ts +4 -0
  17. package/dist/sdk/remote/webClient.js +444 -0
  18. package/dist/sdk/sessions/planMode.d.ts +26 -1
  19. package/dist/sdk/sessions/planMode.js +48 -1
  20. package/dist/sdk/sessions/runner.d.ts +2 -0
  21. package/dist/sdk/sessions/runner.js +17 -2
  22. package/dist/sdk/sessions/sessions.js +1 -0
  23. package/dist/sdk/sessions/workSlice.d.ts +5 -9
  24. package/dist/sdk/sessions/workSlice.js +8 -149
  25. package/dist/sdk/terminal/client.d.ts +2 -0
  26. package/dist/sdk/terminal/client.js +2 -0
  27. package/dist/sdk/terminal/controller.d.ts +13 -0
  28. package/dist/sdk/terminal/controller.js +56 -2
  29. package/dist/sdk/terminal/piExtension.js +71 -3
  30. package/dist/sdk/terminal/stallWatch.d.ts +13 -0
  31. package/dist/sdk/terminal/stallWatch.js +16 -0
  32. package/dist/sdk/terminal/standalone.d.ts +2 -0
  33. package/dist/sdk/terminal/standalone.js +9 -2
  34. package/dist/sdk/terminal/terminals.d.ts +12 -0
  35. package/dist/sdk/terminal/terminals.js +87 -2
  36. package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
  37. package/dist/sdk/terminal/tui/commandPages.js +12 -0
  38. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  39. package/dist/sdk/terminal/tui/commands.js +18 -1
  40. package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
  41. package/dist/sdk/terminal/tui/markdown.js +55 -0
  42. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +8 -0
  43. package/dist/sdk/terminal/tui/pages/contextPage.js +130 -39
  44. package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
  45. package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
  46. package/dist/sdk/terminal/tui/qr.d.ts +4 -0
  47. package/dist/sdk/terminal/tui/qr.js +35 -0
  48. package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
  49. package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
  50. package/dist/sdk/terminal/tui/status.d.ts +6 -0
  51. package/dist/sdk/terminal/tui/status.js +15 -1
  52. package/dist/sdk/terminal/tui/theme.d.ts +9 -0
  53. package/dist/sdk/terminal/tui/theme.js +9 -0
  54. package/dist/terminal-opentui.js +959 -174
  55. package/dist/terminal-pi.js +114 -116
  56. package/docs/architecture/02-sessions-and-runs.md +46 -22
  57. package/docs/architecture/04-prompt.md +10 -0
  58. package/docs/architecture/07-tools-and-gate.md +3 -1
  59. package/docs/architecture/10-server-protocol.md +24 -0
  60. package/docs/architecture/11-terminal.md +40 -8
  61. package/docs/architecture/12-storage.md +5 -2
  62. package/docs/architecture/14-surfaces-and-parity.md +4 -0
  63. package/docs/architecture/15-remote-control.md +118 -0
  64. package/docs/architecture/README.md +2 -0
  65. package/docs/architecture/proposals/PROP-0049-context-attribution.md +28 -1
  66. package/docs/architecture/proposals/README.md +2 -2
  67. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
  68. package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
  69. package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
  70. package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
  71. package/docs/decisions/ADR-0056-remote-control.md +53 -0
  72. package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
  73. package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
  74. package/docs/parity.md +8 -4
  75. package/docs/releases/1.3.1-unstable.4.md +56 -0
  76. package/docs/terminal-tui.md +15 -3
  77. package/package.json +3 -1
@@ -25,6 +25,7 @@ import type { AnnotationCapture } from "../annotations/capture.js";
25
25
  import type { NoteDeliveries } from "../annotations/delivery.js";
26
26
  import type { Usage } from "../usage/service.js";
27
27
  import type { UsageLimits } from "../usage/limits/service.js";
28
+ import { type RemoteControlOptions } from "../remote/service.js";
28
29
  /** The files a terminal session needs on disk, outside this process. */
29
30
  export interface TerminalAssets {
30
31
  /** The Node binary that runs the commands. */
@@ -110,6 +111,8 @@ export interface TerminalsOptions {
110
111
  usage?: Usage;
111
112
  /** Provider plan windows, balances and rate limits (`/usage:limit`). */
112
113
  usageLimits?: UsageLimits;
114
+ /** Where `/remote-control` listens; tests bind loopback here. */
115
+ remoteControl?: RemoteControlOptions;
113
116
  }
114
117
  /** What `/plugins` in a terminal can do. Each takes the session's folder, account and project. */
115
118
  export interface PluginControl {
@@ -156,6 +159,8 @@ export declare class Terminals {
156
159
  private readonly relaunches;
157
160
  /** The managed worktree each live session holds. */
158
161
  private readonly worktreeLeases;
162
+ /** `/remote-control` for each live session that opened it. */
163
+ private readonly remotes;
159
164
  /** Each child's profile name and colour, looked up once per child run. */
160
165
  private readonly childProfiles;
161
166
  private subagentProfile;
@@ -241,6 +246,13 @@ export declare class Terminals {
241
246
  active: boolean;
242
247
  };
243
248
  has(sessionId: string): boolean;
249
+ private remote;
250
+ /**
251
+ * What a paired phone reaches: this session's own runs and the same send, interrupt and
252
+ * decision calls the terminal makes (INV-RC-3).
253
+ */
254
+ private remotePort;
255
+ private remoteCall;
244
256
  /** The terminal process ended (or never started): its turn, tokens, and written credentials go with it. */
245
257
  dispose(sessionId: string): Promise<void>;
246
258
  close(): Promise<void>;
@@ -2,7 +2,7 @@
2
2
  // This service prepares the isolated account, compiled context, trusted resource selection and
3
3
  // socket bridge. The default launch runs our frontend with Pi's persistent SDK runtime.
4
4
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
5
- import { dirname, join } from 'node:path';
5
+ import { basename, dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { Type } from 'typebox';
8
8
  import { freezeRun } from "../capabilities/runSnapshot.js";
@@ -24,7 +24,7 @@ import * as variantStore from "../variant/store.js";
24
24
  import { attribution, rowText } from "../context/attribution.js";
25
25
  import { compile, copyToRun, record, render, saveForSession } from "../prompt/compile.js";
26
26
  import { isActiveStatus } from "../sessions/contracts.js";
27
- import { findRun, loadRun, readEvents, recordedSubagents, saveRun, updateRun } from "../sessions/runStore.js";
27
+ import { findRun, listRuns, loadRun, readEvents, recordedSubagents, saveRun, updateRun } from "../sessions/runStore.js";
28
28
  import { combineWorkspaceScope, inspectRunWorkspaceWrite } from "../sessions/workspaceBoundary.js";
29
29
  import { TODO_WRITE } from "../tools/todo.js";
30
30
  import { DEFAULT_TOOL_NAMES } from "../tools/definitions.js";
@@ -46,6 +46,7 @@ import { SessionSocket, socketPath } from "./socket.js";
46
46
  import { harnessLogInfo, readHarnessLogChunk } from "./logs.js";
47
47
  import { listTerminalTrajectoryRuns, readTerminalTrajectory, latestTerminalTodo } from "./trajectory.js";
48
48
  import { BrowserControl } from "./browserControl.js";
49
+ import { RemoteControl } from "../remote/service.js";
49
50
  /** Identity of the exact resource decision that this terminal was prepared with. */
50
51
  function resourceIdentity(loading) {
51
52
  if (!loading)
@@ -85,6 +86,8 @@ export class Terminals {
85
86
  relaunches = new Map();
86
87
  /** The managed worktree each live session holds. */
87
88
  worktreeLeases = new Map();
89
+ /** `/remote-control` for each live session that opened it. */
90
+ remotes = new Map();
88
91
  /** Each child's profile name and colour, looked up once per child run. */
89
92
  childProfiles = new Map();
90
93
  subagentProfile(record) {
@@ -1301,6 +1304,8 @@ export class Terminals {
1301
1304
  return this.usageCall(live, call, params);
1302
1305
  if (call === 'terminal.context')
1303
1306
  return this.contextCall(live, params);
1307
+ if (call.startsWith('terminal.remoteControl.'))
1308
+ return this.remoteCall(live, call, params);
1304
1309
  if (call !== 'terminal.resources')
1305
1310
  return live.controller.control(call, params);
1306
1311
  const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, preparedAccount), install: false, caller: { projectId: input.projectId ?? null } }) ?? null;
@@ -1403,6 +1408,83 @@ export class Terminals {
1403
1408
  has(sessionId) {
1404
1409
  return this.sessions.has(sessionId);
1405
1410
  }
1411
+ remote(live) {
1412
+ const sessionId = live.input.sessionId;
1413
+ let remote = this.remotes.get(sessionId);
1414
+ if (!remote) {
1415
+ remote = new RemoteControl(this.remotePort(live), { productName: this.options.env.productName, ...this.options.remoteControl });
1416
+ this.remotes.set(sessionId, remote);
1417
+ }
1418
+ return remote;
1419
+ }
1420
+ /**
1421
+ * What a paired phone reaches: this session's own runs and the same send, interrupt and
1422
+ * decision calls the terminal makes (INV-RC-3).
1423
+ */
1424
+ remotePort(live) {
1425
+ const { root } = this.options.env;
1426
+ const sessionId = live.input.sessionId;
1427
+ const own = new Map();
1428
+ const isOwn = (runId) => {
1429
+ let known = own.get(runId);
1430
+ if (known === undefined) {
1431
+ const record = findRun(root, runId);
1432
+ known = !!record && !record.relation && record.trigger !== 'side-question';
1433
+ if (own.size > 1000)
1434
+ own.clear();
1435
+ if (record)
1436
+ own.set(runId, known);
1437
+ }
1438
+ return known;
1439
+ };
1440
+ return {
1441
+ sessionId,
1442
+ title: () => basename(live.input.cwd) || this.options.env.productName,
1443
+ send: (text) => this.send({ sessionId, text }),
1444
+ control: (call, params) => live.controller.control(call, params),
1445
+ history: () => {
1446
+ const native = live.controller.nativeSessionId;
1447
+ if (!native)
1448
+ return [];
1449
+ const runs = listRuns(root, { sessionId })
1450
+ .filter((run) => run.nativeSessionId === native && !run.relation && run.trigger !== 'side-question')
1451
+ .slice(0, 20)
1452
+ .reverse();
1453
+ return runs.flatMap((run) => readEvents(root, run.id));
1454
+ },
1455
+ listen: (listener) => this.options.runs.listen((event) => {
1456
+ if (event.sessionId === sessionId && isOwn(event.runId))
1457
+ listener(event);
1458
+ }),
1459
+ state: () => {
1460
+ const state = live.controller.state();
1461
+ return { ready: state.ready, active: state.active };
1462
+ }
1463
+ };
1464
+ }
1465
+ async remoteCall(live, call, params) {
1466
+ const remote = this.remote(live);
1467
+ if (call === 'terminal.remoteControl.status')
1468
+ return remote.status();
1469
+ if (call === 'terminal.remoteControl.status.wait')
1470
+ return remote.waitStatus(params.revision);
1471
+ if (call === 'terminal.remoteControl.start') {
1472
+ const choice = params.interface ?? 'auto';
1473
+ if (!['auto', 'tailscale', 'lan'].includes(choice))
1474
+ throw invalid('Choose auto, tailscale or lan.');
1475
+ return remote.start(choice);
1476
+ }
1477
+ if (call === 'terminal.remoteControl.stop')
1478
+ return remote.stop();
1479
+ if (call === 'terminal.remoteControl.rotate')
1480
+ return remote.rotate();
1481
+ if (call === 'terminal.remoteControl.confirm') {
1482
+ if (typeof params.requestId !== 'string' || typeof params.allow !== 'boolean')
1483
+ throw invalid('A pairing request id and a boolean choice are required.');
1484
+ return remote.confirm(params.requestId, params.allow);
1485
+ }
1486
+ throw new HarnessError('unknown_method', `Unknown terminal control ${call}.`);
1487
+ }
1406
1488
  /** The terminal process ended (or never started): its turn, tokens, and written credentials go with it. */
1407
1489
  async dispose(sessionId) {
1408
1490
  const live = this.sessions.get(sessionId);
@@ -1415,6 +1497,9 @@ export class Terminals {
1415
1497
  }
1416
1498
  this.sessions.delete(sessionId);
1417
1499
  this.worldNames.delete(sessionId);
1500
+ const remote = this.remotes.get(sessionId);
1501
+ this.remotes.delete(sessionId);
1502
+ await remote?.dispose().catch(() => undefined);
1418
1503
  const worktreeId = this.worktreeLeases.get(sessionId);
1419
1504
  this.worktreeLeases.delete(sessionId);
1420
1505
  if (worktreeId)
@@ -1,7 +1,8 @@
1
1
  import type { TerminalPage } from "./page.js";
2
2
  import type { TerminalCommandHost } from "./commands.js";
3
3
  import { type AnnotateStart } from "./pages/annotatePage.js";
4
- export type CommandPageId = 'doctor' | 'accounts' | 'permissions' | 'plugins' | 'hub' | 'components' | 'settings' | 'trajectory' | 'log' | 'harness' | 'skills' | 'connections' | 'tools' | 'worktree' | 'notes' | 'annotate' | 'subagents' | 'agents' | 'usage';
4
+ import { type ContextTab } from "./pages/contextPage.js";
5
+ export type CommandPageId = 'doctor' | 'accounts' | 'permissions' | 'plugins' | 'hub' | 'components' | 'settings' | 'trajectory' | 'log' | 'harness' | 'skills' | 'connections' | 'tools' | 'worktree' | 'notes' | 'annotate' | 'subagents' | 'agents' | 'usage' | 'context' | 'remote-control';
5
6
  /** Construct one independent full-screen page for a slash command. */
6
7
  export declare function createCommandPage(host: TerminalCommandHost, id: CommandPageId, options?: {
7
8
  runId?: string;
@@ -9,5 +10,7 @@ export declare function createCommandPage(host: TerminalCommandHost, id: Command
9
10
  annotate?: AnnotateStart;
10
11
  usageTab?: 'analysis' | 'limit' | 'accounts';
11
12
  usagePeriod?: 'today' | '7d' | '30d' | 'all';
13
+ contextTab?: ContextTab;
12
14
  close?(): void;
15
+ navigate?(id: CommandPageId): void;
13
16
  }): TerminalPage;
@@ -12,12 +12,24 @@ import { AnnotatePage } from "./pages/annotatePage.js";
12
12
  import { SubagentsPage } from "./pages/subagentsPage.js";
13
13
  import { AgentsPage } from "./pages/agentsPage.js";
14
14
  import { UsagePage } from "./pages/usagePage.js";
15
+ import { ContextPage } from "./pages/contextPage.js";
16
+ import { RemoteControlPage } from "./pages/remoteControlPage.js";
15
17
  import { createSettingsModel } from "./settingsModel.js";
16
18
  /** Construct one independent full-screen page for a slash command. */
17
19
  export function createCommandPage(host, id, options = {}) {
18
20
  const theme = () => host.ui.theme;
19
21
  const notify = (message, type) => host.ui.notify(message, type === 'error' || type === 'warning' ? type : 'info');
20
22
  switch (id) {
23
+ case 'remote-control':
24
+ return new RemoteControlPage({ theme, call: host.control, changed: host.changed, copy: host.copyText, notify });
25
+ case 'context':
26
+ return new ContextPage({
27
+ theme, call: host.control, changed: host.changed,
28
+ initialTab: options.contextTab === 'growth' ? 'growth' : 'request',
29
+ openVariant: () => options.navigate?.('harness'),
30
+ compact: async () => { await host.session().compact?.(); },
31
+ confirm: (title, message) => host.ui.confirm(title, message)
32
+ });
21
33
  case 'usage':
22
34
  return new UsagePage({ theme, call: host.control, changed: host.changed, initialTab: options.usageTab === 'limit' ? 'limits' : options.usageTab === 'accounts' ? 'accounts' : 'analysis', ...(options.usagePeriod ? { initialPeriod: options.usagePeriod } : {}) });
23
35
  case 'doctor':
@@ -34,6 +34,8 @@ export interface TerminalCommandHost {
34
34
  /** Opens a file in the person's editor and resolves once the editor closes. */
35
35
  editFile?(path: string): Promise<void>;
36
36
  hotkeys?(): string;
37
+ /** Follow `/remote-control` after it starts: the status bar, and pairing requests. */
38
+ watchRemoteControl?(): void;
37
39
  logs: {
38
40
  read(): Promise<string>;
39
41
  };
@@ -44,7 +44,7 @@ export function createTerminalCommands(host) {
44
44
  };
45
45
  async function openPage(id, options = {}) {
46
46
  let close = () => { };
47
- const page = createCommandPage(host, id, { ...options, close: () => close() });
47
+ const page = createCommandPage(host, id, { ...options, close: () => close(), navigate: (target) => { close(); setTimeout(() => void openPage(target), 0); } });
48
48
  await host.ui.custom((tui, _theme, _keys, done) => {
49
49
  close = () => done(undefined);
50
50
  return new PageOverlay({ page, tui, theme: () => host.ui.theme, close, changed: host.changed });
@@ -449,6 +449,23 @@ export function createTerminalCommands(host) {
449
449
  case 'log':
450
450
  await openPage('log');
451
451
  break;
452
+ case 'context':
453
+ await openPage('context', { contextTab: argument === 'growth' ? 'growth' : 'request' });
454
+ break;
455
+ case 'remote-control': {
456
+ const choice = argument.toLowerCase();
457
+ if (choice === 'off' || choice === 'stop') {
458
+ await host.control('terminal.remoteControl.stop');
459
+ notify('Remote control stopped.');
460
+ break;
461
+ }
462
+ if (choice && !['tailscale', 'lan', 'wifi', 'wi-fi'].includes(choice))
463
+ throw new Error('Use /remote-control, /remote-control off, /remote-control tailscale or /remote-control lan.');
464
+ await host.control('terminal.remoteControl.start', { interface: choice === 'tailscale' ? 'tailscale' : choice ? 'lan' : 'auto' });
465
+ host.watchRemoteControl?.();
466
+ await openPage('remote-control');
467
+ break;
468
+ }
452
469
  case 'compact':
453
470
  await session.compact(argument || undefined);
454
471
  break;
@@ -0,0 +1,16 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent';
2
+ import { type MarkdownTheme } from '@earendil-works/pi-tui';
3
+ /** The Harness markdown colours, mapped onto the component's callbacks. */
4
+ export declare function markdownTheme(theme: Theme): MarkdownTheme;
5
+ /** Model output as markdown source: terminal control sequences never reach the renderer. */
6
+ export declare function markdownSource(text: string): string;
7
+ /**
8
+ * One surface's Markdown component. It re-renders only when the streamed text, the width or the
9
+ * theme changes, and drops trailing blank rows so the caller frames the block itself.
10
+ */
11
+ export declare class MarkdownBlock {
12
+ private component;
13
+ private theme;
14
+ private text;
15
+ lines(text: string, width: number, theme: Theme): string[];
16
+ }
@@ -0,0 +1,55 @@
1
+ import { Markdown, stripTerminalSequences } from '@earendil-works/pi-tui';
2
+ /** The Harness markdown colours, mapped onto the component's callbacks. */
3
+ export function markdownTheme(theme) {
4
+ return {
5
+ heading: text => theme.fg('mdHeading', text),
6
+ link: text => theme.fg('mdLink', text),
7
+ linkUrl: text => theme.fg('mdLinkUrl', text),
8
+ code: text => theme.fg('mdCode', text),
9
+ codeBlock: text => theme.fg('mdCodeBlock', text),
10
+ codeBlockBorder: text => theme.fg('mdCodeBlockBorder', text),
11
+ quote: text => theme.fg('mdQuote', text),
12
+ quoteBorder: text => theme.fg('mdQuoteBorder', text),
13
+ hr: text => theme.fg('mdHr', text),
14
+ listBullet: text => theme.fg('mdListBullet', text),
15
+ bold: text => theme.bold(text),
16
+ italic: text => theme.italic(text),
17
+ underline: text => theme.underline(text),
18
+ strikethrough: text => theme.strikethrough(text),
19
+ // A block surface has no syntax highlighter; its code keeps the theme's code colour.
20
+ highlightCode: code => code.split('\n').map(line => theme.fg('mdCodeBlock', line))
21
+ };
22
+ }
23
+ /** Model output as markdown source: terminal control sequences never reach the renderer. */
24
+ export function markdownSource(text) {
25
+ return stripTerminalSequences(text)
26
+ .replace(/\r\n?/gu, '\n')
27
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/gu, ' ');
28
+ }
29
+ /**
30
+ * One surface's Markdown component. It re-renders only when the streamed text, the width or the
31
+ * theme changes, and drops trailing blank rows so the caller frames the block itself.
32
+ */
33
+ export class MarkdownBlock {
34
+ component;
35
+ theme;
36
+ text = '';
37
+ lines(text, width, theme) {
38
+ if (width < 1 || !text.trim())
39
+ return [];
40
+ if (!this.component || this.theme !== theme) {
41
+ this.component = new Markdown('', 0, 0, markdownTheme(theme));
42
+ this.theme = theme;
43
+ this.text = '';
44
+ }
45
+ if (this.text !== text) {
46
+ this.component.setText(text);
47
+ this.text = text;
48
+ }
49
+ const lines = this.component.render(width);
50
+ let end = lines.length;
51
+ while (end > 0 && !stripTerminalSequences(lines[end - 1]).trim())
52
+ end -= 1;
53
+ return lines.slice(0, end);
54
+ }
55
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Theme } from '@earendil-works/pi-coding-agent';
2
+ import type { ContextAttribution } from "../../../context/types.js";
2
3
  import { type TabInfo } from "../chrome.js";
3
4
  import type { TerminalPage } from "../page.js";
4
5
  export type ContextTab = 'request' | 'growth';
@@ -14,6 +15,13 @@ interface ContextPageOptions {
14
15
  compact?(): Promise<void>;
15
16
  confirm?(title: string, message: string): Promise<boolean>;
16
17
  }
18
+ /** Cells for each value out of `total`, by largest remainder, so the runs always add up. */
19
+ export declare function allocateCells(values: readonly number[], total: number): number[];
20
+ /** The bar and its legend: one colour per kind of context, the track for free space, a marker at the reserve. */
21
+ export declare function contextBar(data: ContextAttribution, width: number, theme: Theme): {
22
+ bar: string;
23
+ legend: string[];
24
+ };
17
25
  export declare class ContextPage implements TerminalPage {
18
26
  private readonly options;
19
27
  presentation: "fullscreen";
@@ -1,6 +1,9 @@
1
- import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
- import { CHROME_PAD, headerRow, neutralRule, padded, selectedRow, selectionMuted, selectionText } from "../chrome.js";
1
+ import { matchesKey, parseColor, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
+ import { CHROME_PAD, padded, selectedRow } from "../chrome.js";
3
+ import { SERIES_COLORS } from "../theme.js";
3
4
  const BARS = '▁▂▃▄▅▆▇█';
5
+ /** Characters per token, for the budget column: the same ratio the attribution estimates with. */
6
+ const CHARS = 4;
4
7
  const OWNER_TITLES = {
5
8
  foundation: 'Foundation · harness',
6
9
  variant: 'Variant',
@@ -35,23 +38,103 @@ function count(value) {
35
38
  function percent(value) {
36
39
  return value === null ? '?' : `${value.toFixed(1)}%`;
37
40
  }
41
+ /** One rule for capacity, shared with the App's `Meter`: amber at 80, red at 95. */
38
42
  function tone(share) {
39
43
  if (share === null)
40
44
  return 'dim';
41
- if (share >= 90)
45
+ if (share >= 95)
42
46
  return 'error';
43
- if (share >= 70)
47
+ if (share >= 80)
44
48
  return 'warning';
45
49
  return 'success';
46
50
  }
47
- /** A context bar: filled as the window is used, with room left in the track. */
48
- function usageBar(share, width, theme) {
51
+ /** What a bar's run is called, short enough to sit in a legend. */
52
+ const SERIES_LABELS = {
53
+ foundation: 'foundation',
54
+ variant: 'variant',
55
+ application: 'your app',
56
+ conversation: 'conversation',
57
+ reserved: 'reserved'
58
+ };
59
+ /** The categorical colour of an owner group: the App's `--data-1..4`, in the same order. */
60
+ function seriesColor(theme, index) {
61
+ const palette = theme.appearance === 'light' ? SERIES_COLORS.light : SERIES_COLORS.dark;
62
+ return palette[index] ?? palette[palette.length - 1];
63
+ }
64
+ /** Cells for each value out of `total`, by largest remainder, so the runs always add up. */
65
+ export function allocateCells(values, total) {
66
+ const cells = Math.max(0, Math.floor(total));
67
+ const sum = values.reduce((acc, value) => acc + Math.max(0, value), 0);
68
+ if (sum <= 0 || cells <= 0)
69
+ return values.map(() => 0);
70
+ const present = values.map((value, index) => ({ value: Math.max(0, value), index })).filter((entry) => entry.value > 0);
71
+ if (cells < present.length) {
72
+ const out = values.map(() => 0);
73
+ for (const entry of [...present].sort((left, right) => right.value - left.value).slice(0, cells))
74
+ out[entry.index] = 1;
75
+ return out;
76
+ }
77
+ const exact = values.map((value) => (Math.max(0, value) / sum) * cells);
78
+ const out = exact.map((value) => Math.floor(value));
79
+ let left = cells - out.reduce((acc, value) => acc + value, 0);
80
+ for (const entry of exact.map((value, index) => ({ rest: value - Math.floor(value), index })).sort((left_, right) => right.rest - left_.rest)) {
81
+ if (left <= 0)
82
+ break;
83
+ out[entry.index] = (out[entry.index] ?? 0) + 1;
84
+ left -= 1;
85
+ }
86
+ // A kind with tokens gets one cell while there is room to spare, so it never vanishes silently.
87
+ for (const entry of present) {
88
+ if ((out[entry.index] ?? 0) > 0)
89
+ continue;
90
+ const donor = out.map((value, index) => ({ value, index })).filter((candidate) => candidate.index !== entry.index && candidate.value > 1).sort((a, b) => b.value - a.value)[0];
91
+ if (!donor)
92
+ break;
93
+ out[donor.index] = (out[donor.index] ?? 0) - 1;
94
+ out[entry.index] = 1;
95
+ }
96
+ return out;
97
+ }
98
+ /** The bar and its legend: one colour per kind of context, the track for free space, a marker at the reserve. */
99
+ export function contextBar(data, width, theme) {
49
100
  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))}]`;
101
+ const parts = OWNER_ORDER.slice(0, 4)
102
+ .map((group, index) => ({ group, tokens: data.totals[group], color: seriesColor(theme, index) }))
103
+ .filter((part) => part.tokens > 0);
104
+ const window = data.window;
105
+ const share = window === null || window <= 0 ? 100 : Math.min(100, (data.used / window) * 100);
106
+ const filled = Math.max(0, Math.min(room, Math.round((share / 100) * room)));
107
+ const cells = allocateCells(parts.map((part) => part.tokens), filled);
108
+ const runs = parts.flatMap((part, index) => (cells[index] ?? 0) > 0 ? [{ text: '█'.repeat(cells[index]), color: part.color }] : []);
109
+ const trackCells = Math.max(0, room - filled);
110
+ const reserve = data.compaction?.reserveTokens ?? null;
111
+ const markerAt = reserve !== null && window !== null && window > 0 ? Math.round(((data.used + reserve) / window) * room) : null;
112
+ const inTrack = markerAt !== null && markerAt >= filled && markerAt < room ? markerAt - filled : null;
113
+ const bar = '[' + runs.map((run) => theme.style(run.text, { fg: parseColor(run.color) })).join('')
114
+ + (trackCells > 0
115
+ ? theme.fg('dim', '░'.repeat(inTrack ?? trackCells))
116
+ + (inTrack === null ? '' : theme.fg('warning', '│') + theme.fg('dim', '░'.repeat(Math.max(0, trackCells - inTrack - 1))))
117
+ : (inTrack === null ? '' : theme.fg('warning', '│')))
118
+ + ']';
119
+ const entries = parts.map((part) => ({ text: `▌${SERIES_LABELS[part.group]} ${count(part.tokens)}`, color: part.color }));
120
+ entries.push({ text: `free ${data.free === null ? '?' : count(data.free)}`, color: null });
121
+ if (reserve !== null)
122
+ entries.push({ text: `│ compaction at ${count(Math.min(window ?? data.used + reserve, data.used + reserve))}`, color: null });
123
+ const legend = [];
124
+ let line = '';
125
+ for (const entry of entries) {
126
+ const piece = entry.color ? theme.style(entry.text, { fg: parseColor(entry.color) }) : theme.fg('dim', entry.text);
127
+ const candidate = line ? `${line} ${piece}` : piece;
128
+ if (line && visibleWidth(candidate) > width) {
129
+ legend.push(line);
130
+ line = piece;
131
+ }
132
+ else
133
+ line = candidate;
134
+ }
135
+ if (line)
136
+ legend.push(line);
137
+ return { bar, legend };
55
138
  }
56
139
  /** A label on the left and a quiet value at the right edge of the same row. */
57
140
  function spread(left, right, width) {
@@ -130,6 +213,9 @@ export class ContextPage {
130
213
  if (this.disposed)
131
214
  return;
132
215
  this.data = answer.attribution ?? null;
216
+ // Start on the first row so space and enter act on something the person can see.
217
+ if (!this.entries().some((entry) => entry.row.id === this.selectedId))
218
+ this.selectedId = this.entries()[0]?.row.id ?? null;
133
219
  if (section) {
134
220
  if (answer.text)
135
221
  this.detail = answer.text;
@@ -177,7 +263,10 @@ export class ContextPage {
177
263
  return true;
178
264
  }
179
265
  const entries = this.entries();
180
- const current = Math.max(0, entries.findIndex((entry) => entry.row.id === this.selectedId));
266
+ const found = entries.findIndex((entry) => entry.row.id === this.selectedId);
267
+ const current = found >= 0 ? found : 0;
268
+ if (!entries.length)
269
+ return false;
181
270
  if (matchesKey(data, 'up'))
182
271
  this.move(entries, current - 1);
183
272
  else if (matchesKey(data, 'down'))
@@ -271,21 +360,18 @@ export class ContextPage {
271
360
  if (width < 1 || rows < 1)
272
361
  return [];
273
362
  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)];
363
+ // The overlay owns the tab strip, both rules and the hint; the body starts at the reading.
277
364
  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);
365
+ const room = Math.max(1, rows);
366
+ this.scrollRows = room;
367
+ this.maxOffset = Math.max(0, body.length - room);
368
+ const wanted = this.detail ? this.detailOffset : this.offset;
369
+ const offset = Math.max(0, Math.min(wanted, this.maxOffset));
283
370
  if (this.detail)
284
371
  this.detailOffset = offset;
285
372
  else
286
373
  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, ''));
374
+ return body.slice(offset, offset + room).map((line) => truncateToWidth(line, width, ''));
289
375
  }
290
376
  requestBody(width, theme) {
291
377
  const data = this.data;
@@ -295,19 +381,23 @@ export class ContextPage {
295
381
  const inner = Math.max(8, width - CHROME_PAD * 2);
296
382
  const body = [];
297
383
  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));
384
+ const chart = contextBar(data, inner, theme);
385
+ body.push(padded(chart.bar, width));
386
+ for (const line of chart.legend)
387
+ body.push(padded(line, width));
299
388
  body.push('');
300
389
  for (const entry of this.entries()) {
301
390
  if (entry.heading) {
302
391
  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));
392
+ // The heading carries its group's colour, so the list and the bar read as one picture.
393
+ const owner = this.grouping === 'owner' ? OWNER_ORDER.find((group) => OWNER_TITLES[group] === entry.heading) : undefined;
394
+ const swatch = owner ? theme.style('█ ', { fg: parseColor(seriesColor(theme, OWNER_ORDER.indexOf(owner))) }) : '';
395
+ body.push(padded(swatch + theme.bold(theme.fg('accent', entry.heading)) + theme.fg('dim', ` ${count(total)}`), width));
304
396
  }
305
397
  if (entry.folded)
306
398
  continue;
307
399
  body.push(this.rowLine(entry.row, inner, width, theme));
308
400
  }
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
401
  if (data.onDemand.length) {
312
402
  body.push('');
313
403
  body.push(padded(theme.fg('accent', 'On demand') + theme.fg('dim', ' not in this request'), width));
@@ -327,22 +417,23 @@ export class ContextPage {
327
417
  rowLine(row, inner, width, theme) {
328
418
  const selected = row.id === this.selectedId;
329
419
  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
420
  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}`;
421
+ // The note says why a figure is not exact, so it outranks the budget and the tool breakdown.
422
+ const extras = [
423
+ row.note ? row.note : '',
424
+ row.budget ? `${count(row.chars ?? row.tokens * CHARS)} of ${count(row.budget)} chars` : '',
425
+ row.tools?.length ? row.tools.map((tool) => `${tool.group} ${tool.count}`).join(' · ') : ''
426
+ ].filter(Boolean);
427
+ const room = () => Math.max(12, inner - visibleWidth(tail) - 1 - (extras.length ? visibleWidth(extras.join(' ')) + 2 : 0));
428
+ let left = truncateToWidth(`${marker}${row.title}`, room(), '…');
429
+ while (extras.length && visibleWidth(left) + visibleWidth(tail) + 2 + visibleWidth(extras.join(' ')) > inner)
430
+ extras.pop();
431
+ const rest = extras.join(' ');
432
+ const gap = Math.max(1, inner - visibleWidth(left) - visibleWidth(tail) - (rest ? visibleWidth(rest) + 2 : 0));
433
+ const line = `${left}${' '.repeat(gap)}${rest ? theme.fg('dim', rest) + ' ' : ''}${tail}`;
338
434
  if (selected)
339
435
  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);
436
+ return padded(line, width);
346
437
  }
347
438
  growthBody(width, theme) {
348
439
  const data = this.data;
@@ -0,0 +1,32 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent';
2
+ import type { TerminalPage } from "../page.js";
3
+ export interface RemoteControlPageOptions {
4
+ theme(): Theme;
5
+ call<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
6
+ changed(): void;
7
+ copy(text: string): Promise<void>;
8
+ notify(message: string, type?: 'info' | 'warning' | 'error'): void;
9
+ }
10
+ export declare class RemoteControlPage implements TerminalPage {
11
+ private readonly options;
12
+ presentation: "fullscreen";
13
+ private status;
14
+ private error;
15
+ private showQr;
16
+ private busy;
17
+ private disposed;
18
+ private watching;
19
+ constructor(options: RemoteControlPageOptions);
20
+ tabs(): {
21
+ id: string;
22
+ title: string;
23
+ }[];
24
+ activeTab(): string;
25
+ hint(): string;
26
+ activate(): void;
27
+ dispose(): void;
28
+ private watch;
29
+ private act;
30
+ handleInput(data: string): boolean;
31
+ render(width: number, rows: number): string[];
32
+ }