@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.
- package/CHANGELOG.md +33 -0
- package/dist/cli.js +2513 -1296
- package/dist/sdk/cli.js +2 -0
- package/dist/sdk/commands/registry.d.ts +15 -0
- package/dist/sdk/commands/registry.js +2 -0
- package/dist/sdk/remote/addresses.d.ts +19 -0
- package/dist/sdk/remote/addresses.js +52 -0
- package/dist/sdk/remote/pairing.d.ts +59 -0
- package/dist/sdk/remote/pairing.js +160 -0
- package/dist/sdk/remote/seal.d.ts +28 -0
- package/dist/sdk/remote/seal.js +84 -0
- package/dist/sdk/remote/server.d.ts +28 -0
- package/dist/sdk/remote/server.js +158 -0
- package/dist/sdk/remote/service.d.ts +125 -0
- package/dist/sdk/remote/service.js +330 -0
- package/dist/sdk/remote/webClient.d.ts +4 -0
- package/dist/sdk/remote/webClient.js +444 -0
- package/dist/sdk/sessions/planMode.d.ts +26 -1
- package/dist/sdk/sessions/planMode.js +48 -1
- package/dist/sdk/sessions/runner.d.ts +2 -0
- package/dist/sdk/sessions/runner.js +17 -2
- package/dist/sdk/sessions/sessions.js +1 -0
- package/dist/sdk/sessions/workSlice.d.ts +5 -9
- package/dist/sdk/sessions/workSlice.js +8 -149
- package/dist/sdk/terminal/client.d.ts +2 -0
- package/dist/sdk/terminal/client.js +2 -0
- package/dist/sdk/terminal/controller.d.ts +13 -0
- package/dist/sdk/terminal/controller.js +56 -2
- package/dist/sdk/terminal/piExtension.js +71 -3
- package/dist/sdk/terminal/stallWatch.d.ts +13 -0
- package/dist/sdk/terminal/stallWatch.js +16 -0
- package/dist/sdk/terminal/standalone.d.ts +2 -0
- package/dist/sdk/terminal/standalone.js +9 -2
- package/dist/sdk/terminal/terminals.d.ts +12 -0
- package/dist/sdk/terminal/terminals.js +87 -2
- package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
- package/dist/sdk/terminal/tui/commandPages.js +12 -0
- package/dist/sdk/terminal/tui/commands.d.ts +2 -0
- package/dist/sdk/terminal/tui/commands.js +18 -1
- package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
- package/dist/sdk/terminal/tui/markdown.js +55 -0
- package/dist/sdk/terminal/tui/pages/contextPage.d.ts +8 -0
- package/dist/sdk/terminal/tui/pages/contextPage.js +130 -39
- package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
- package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
- package/dist/sdk/terminal/tui/qr.d.ts +4 -0
- package/dist/sdk/terminal/tui/qr.js +35 -0
- package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
- package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
- package/dist/sdk/terminal/tui/status.d.ts +6 -0
- package/dist/sdk/terminal/tui/status.js +15 -1
- package/dist/sdk/terminal/tui/theme.d.ts +9 -0
- package/dist/sdk/terminal/tui/theme.js +9 -0
- package/dist/terminal-opentui.js +959 -174
- package/dist/terminal-pi.js +114 -116
- package/docs/architecture/02-sessions-and-runs.md +46 -22
- package/docs/architecture/04-prompt.md +10 -0
- package/docs/architecture/07-tools-and-gate.md +3 -1
- package/docs/architecture/10-server-protocol.md +24 -0
- package/docs/architecture/11-terminal.md +40 -8
- package/docs/architecture/12-storage.md +5 -2
- package/docs/architecture/14-surfaces-and-parity.md +4 -0
- package/docs/architecture/15-remote-control.md +118 -0
- package/docs/architecture/README.md +2 -0
- package/docs/architecture/proposals/PROP-0049-context-attribution.md +28 -1
- package/docs/architecture/proposals/README.md +2 -2
- package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
- package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
- package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
- package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
- package/docs/decisions/ADR-0056-remote-control.md +53 -0
- package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
- package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
- package/docs/parity.md +8 -4
- package/docs/releases/1.3.1-unstable.4.md +56 -0
- package/docs/terminal-tui.md +15 -3
- 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
|
-
|
|
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,
|
|
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 >=
|
|
45
|
+
if (share >= 95)
|
|
42
46
|
return 'error';
|
|
43
|
-
if (share >=
|
|
47
|
+
if (share >= 80)
|
|
44
48
|
return 'warning';
|
|
45
49
|
return 'success';
|
|
46
50
|
}
|
|
47
|
-
/**
|
|
48
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
this.
|
|
281
|
-
const
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
+
}
|