@jameslovespancakes/pi-plus 1.0.18 → 1.0.20
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/README.md +117 -248
- package/package.json +3 -2
- package/src/core/claude-remote/LICENSE.md +22 -0
- package/src/core/claude-remote/UPSTREAM.md +40 -0
- package/src/core/claude-remote/bridge.ts +392 -0
- package/src/core/claude-remote/protocol.ts +83 -0
- package/src/core/env.ts +7 -1
- package/src/core/quota/pool.ts +12 -0
- package/src/domains/claude-remote/auth.ts +25 -0
- package/src/domains/claude-remote/index.ts +183 -0
- package/src/domains/claude-remote/picker.ts +36 -0
- package/src/domains/models/catalog-tool.ts +2 -2
- package/src/domains/models/provider-picker.ts +3 -46
- package/src/domains/setup/index.ts +12 -1
- package/src/domains/workflows/runtime/progress.ts +15 -28
- package/src/ui/settings-picker.ts +26 -0
- package/src/ui/usage-bars.ts +8 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ClaudeRemoteBridge, type BridgeOptions } from "../../core/claude-remote/bridge.ts";
|
|
4
|
+
import { mirrorMessage } from "../../core/claude-remote/protocol.ts";
|
|
5
|
+
import { env, setEnv } from "../../core/env.ts";
|
|
6
|
+
import { createTokenSource } from "./auth.ts";
|
|
7
|
+
import { remoteControlPicker } from "./picker.ts";
|
|
8
|
+
|
|
9
|
+
interface Bridge {
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): void;
|
|
12
|
+
send: ClaudeRemoteBridge["send"];
|
|
13
|
+
reportState: ClaudeRemoteBridge["reportState"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Dependency seams allow lifecycle tests without network access or real credentials. */
|
|
17
|
+
export interface RemoteDependencies {
|
|
18
|
+
bridge(options: BridgeOptions): Bridge;
|
|
19
|
+
tokenSource(): BridgeOptions["getAccessToken"];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaults: RemoteDependencies = {
|
|
23
|
+
bridge: (options) => new ClaudeRemoteBridge(options),
|
|
24
|
+
tokenSource: () => createTokenSource(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies = defaults): void {
|
|
28
|
+
let active: Bridge | undefined;
|
|
29
|
+
let status = "off";
|
|
30
|
+
let enabled = false;
|
|
31
|
+
let current: ExtensionContext | undefined;
|
|
32
|
+
let generation = 0;
|
|
33
|
+
// Counts rather than a TTL: follow-ups can wait longer than 30 seconds.
|
|
34
|
+
const echoes: string[] = [];
|
|
35
|
+
|
|
36
|
+
function notify(ctx: ExtensionContext, message: string, warning = false): void {
|
|
37
|
+
ctx.ui.notify(message, warning ? "warning" : "info");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setConnectionStatus(value: string): void {
|
|
41
|
+
status = value;
|
|
42
|
+
if (!current?.hasUI) return;
|
|
43
|
+
const connected = value.startsWith("connected");
|
|
44
|
+
const label = connected ? (value.includes("read-only") ? "Read-only" : "Active")
|
|
45
|
+
: value === "connecting" ? "Connecting" : "Offline";
|
|
46
|
+
current.ui.setStatus("claude-remote",
|
|
47
|
+
current.ui.theme.fg(connected ? "success" : "error", `● Remote Control ${label}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stop(): void {
|
|
51
|
+
++generation;
|
|
52
|
+
active?.stop();
|
|
53
|
+
active = undefined;
|
|
54
|
+
echoes.length = 0;
|
|
55
|
+
setConnectionStatus("off");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function start(ctx: ExtensionContext): void {
|
|
59
|
+
if (active) {
|
|
60
|
+
notify(ctx, `Claude Remote: ${status}. Open https://claude.ai/code`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
current = ctx;
|
|
64
|
+
const gen = ++generation;
|
|
65
|
+
const title = `pi: ${pi.getSessionName() || basename(ctx.cwd) || "session"}`.slice(0, 100);
|
|
66
|
+
setConnectionStatus("connecting");
|
|
67
|
+
try {
|
|
68
|
+
const allowInbound = !/^(0|false|off|no)$/i.test(env("PI_CLAUDE_REMOTE_ALLOW_INBOUND") ?? "1");
|
|
69
|
+
const bridge = deps.bridge({
|
|
70
|
+
title, allowInbound,
|
|
71
|
+
trustedDeviceToken: env("CLAUDE_TRUSTED_DEVICE_TOKEN"),
|
|
72
|
+
getAccessToken: deps.tokenSource(),
|
|
73
|
+
onText(text) {
|
|
74
|
+
if (generation !== gen || !current || !allowInbound) return;
|
|
75
|
+
if (echoes.length >= 256) throw new Error("Too many remote follow-ups");
|
|
76
|
+
echoes.push(text);
|
|
77
|
+
try {
|
|
78
|
+
pi.sendUserMessage(text, current.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
echoes.splice(echoes.lastIndexOf(text), 1);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
onInterrupt() {
|
|
85
|
+
if (generation === gen && allowInbound) current?.abort();
|
|
86
|
+
},
|
|
87
|
+
onConnect(id) {
|
|
88
|
+
if (generation !== gen) return;
|
|
89
|
+
setConnectionStatus(allowInbound ? "connected" : "connected (read-only)");
|
|
90
|
+
notify(ctx, `Claude Remote: ${title} is live at https://claude.ai/code (${id}).`);
|
|
91
|
+
},
|
|
92
|
+
onConnectionChange(connected) {
|
|
93
|
+
if (generation !== gen) return;
|
|
94
|
+
setConnectionStatus(connected ? (allowInbound ? "connected" : "connected (read-only)") : "connecting");
|
|
95
|
+
},
|
|
96
|
+
onError(message) {
|
|
97
|
+
if (generation !== gen) return;
|
|
98
|
+
stop();
|
|
99
|
+
setConnectionStatus("disconnected");
|
|
100
|
+
notify(ctx, `${message}. Pi continues locally.`, true);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
active = bridge;
|
|
104
|
+
bridge.reportState(ctx.isIdle() ? "idle" : "running");
|
|
105
|
+
void bridge.start().catch(() => {
|
|
106
|
+
if (generation !== gen) return;
|
|
107
|
+
stop();
|
|
108
|
+
setConnectionStatus("disconnected");
|
|
109
|
+
notify(ctx, "Claude Remote could not connect. Run /login for Anthropic, then /claude-remote on.", true);
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
stop();
|
|
113
|
+
notify(ctx, "Claude Remote needs an Anthropic OAuth login. Use /login, then /claude-remote on.", true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function setEnabled(next: boolean, ctx: ExtensionContext): boolean {
|
|
118
|
+
enabled = next;
|
|
119
|
+
const saved = setEnv("PI_CLAUDE_REMOTE", next ? "1" : "0");
|
|
120
|
+
if (next) start(ctx);
|
|
121
|
+
else stop();
|
|
122
|
+
if (!saved) notify(ctx, "Could not save preference; changed this session only.", true);
|
|
123
|
+
else if ((env("PI_CLAUDE_REMOTE") === "1") !== next) {
|
|
124
|
+
notify(ctx, "PI_CLAUDE_REMOTE overrides this preference after reload.", true);
|
|
125
|
+
}
|
|
126
|
+
return enabled;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pi.registerCommand("claude-remote", {
|
|
130
|
+
description: "Remote Control on/off",
|
|
131
|
+
getArgumentCompletions: (prefix) => ["on", "off"]
|
|
132
|
+
.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
|
|
133
|
+
handler: async (args, ctx) => {
|
|
134
|
+
const action = args.trim().toLowerCase();
|
|
135
|
+
if (!action && ctx.mode === "tui") {
|
|
136
|
+
await ctx.ui.custom((_tui, theme, _keys, done) => remoteControlPicker(
|
|
137
|
+
theme, enabled, (next) => setEnabled(next, ctx), () => done(undefined),
|
|
138
|
+
));
|
|
139
|
+
} else if (action === "on" || action === "off") {
|
|
140
|
+
if (action === "on" && ctx.hasUI && !enabled && !await ctx.ui.confirm("Enable Remote Control?",
|
|
141
|
+
"Share sessions with Anthropic and control pi from the Claude app. Auto-starts in interactive sessions.")) return;
|
|
142
|
+
setEnabled(action === "on", ctx);
|
|
143
|
+
} else notify(ctx, "Usage: /claude-remote [on|off]", true);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
pi.on("session_start", (_event, ctx) => {
|
|
148
|
+
stop();
|
|
149
|
+
current = ctx;
|
|
150
|
+
setConnectionStatus("off");
|
|
151
|
+
enabled = env("PI_CLAUDE_REMOTE") === "1";
|
|
152
|
+
// Never spawn remote mirrors for workflow/SDK/print subagents by default.
|
|
153
|
+
if (ctx.mode === "tui" && enabled) start(ctx);
|
|
154
|
+
});
|
|
155
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
156
|
+
stop();
|
|
157
|
+
if (ctx.hasUI) ctx.ui.setStatus("claude-remote", undefined);
|
|
158
|
+
current = undefined;
|
|
159
|
+
});
|
|
160
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
161
|
+
const wasActive = !!active;
|
|
162
|
+
stop();
|
|
163
|
+
current = ctx;
|
|
164
|
+
if (wasActive) start(ctx); // an old branch's remote input must not control a new branch
|
|
165
|
+
});
|
|
166
|
+
pi.on("message_end", (event, ctx) => {
|
|
167
|
+
current = ctx;
|
|
168
|
+
if (!active) return;
|
|
169
|
+
const message = event.message;
|
|
170
|
+
if (message.role === "user") {
|
|
171
|
+
const text = typeof message.content === "string" ? message.content
|
|
172
|
+
: message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
173
|
+
const index = echoes.indexOf(text);
|
|
174
|
+
if (index !== -1) { echoes.splice(index, 1); return; }
|
|
175
|
+
}
|
|
176
|
+
const outbound = mirrorMessage(message);
|
|
177
|
+
if (outbound) active.send(outbound);
|
|
178
|
+
});
|
|
179
|
+
pi.on("agent_start", (_event, ctx) => { current = ctx; active?.reportState("running"); });
|
|
180
|
+
pi.on("agent_end", (_event, ctx) => { current = ctx; active?.reportState("idle"); });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function claudeRemote(pi: ExtensionAPI): void { registerClaudeRemote(pi); }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SettingsList, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { hasTruecolor, levelColor } from "../../ui/format.ts";
|
|
3
|
+
import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
|
|
4
|
+
|
|
5
|
+
/** Same dot, colors and in-place SettingsList toggle as /provider. */
|
|
6
|
+
export function remoteControlPicker(
|
|
7
|
+
theme: any,
|
|
8
|
+
initial: boolean,
|
|
9
|
+
toggle: (enabled: boolean) => boolean,
|
|
10
|
+
done: () => void,
|
|
11
|
+
): Component {
|
|
12
|
+
let enabled = initial;
|
|
13
|
+
const color = (value: boolean, text: string) => hasTruecolor()
|
|
14
|
+
? levelColor(value ? 100 : 0)(text) : theme.fg(value ? "success" : "error", text);
|
|
15
|
+
const label = () => `${color(enabled, "●")} Remote Control`;
|
|
16
|
+
const value = () => color(enabled, enabled ? "On" : "Off");
|
|
17
|
+
const item = {
|
|
18
|
+
id: "remote-control", label: label(), currentValue: value(),
|
|
19
|
+
values: [color(true, "On"), color(false, "Off")],
|
|
20
|
+
};
|
|
21
|
+
const list = new SettingsList([item], 1, settingsTheme(theme), () => {
|
|
22
|
+
enabled = toggle(!enabled);
|
|
23
|
+
item.label = label();
|
|
24
|
+
list.updateValue(item.id, value());
|
|
25
|
+
}, done, { enableSearch: false });
|
|
26
|
+
const frame = frameSettings(theme, list, "Remote Control");
|
|
27
|
+
return {
|
|
28
|
+
...frame,
|
|
29
|
+
invalidate() {
|
|
30
|
+
item.values = [color(true, "On"), color(false, "Off")];
|
|
31
|
+
item.label = label();
|
|
32
|
+
list.updateValue(item.id, value());
|
|
33
|
+
frame.invalidate();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "../../core/catalog/quality.ts";
|
|
15
15
|
import { ensureFresh, usageState } from "../../services/usage-service.ts";
|
|
16
16
|
import { geminiQuotaFamily } from "../../core/gemini/quota.ts";
|
|
17
|
-
import { combinedWindow, isGeminiAccount, pooledWindow } from "../../core/quota/pool.ts";
|
|
17
|
+
import { combinedWindow, isGeminiAccount, pooledWindow, rollOver } from "../../core/quota/pool.ts";
|
|
18
18
|
import { env, isFromProcessEnv, maskSecret, setEnv } from "../../core/env.ts";
|
|
19
19
|
import { fitId } from "../../ui/format.ts";
|
|
20
20
|
|
|
@@ -57,7 +57,7 @@ interface Entry {
|
|
|
57
57
|
|
|
58
58
|
function quotaFor(provider: string, modelId: string): number | undefined {
|
|
59
59
|
const state = usageState();
|
|
60
|
-
const rows = state.rows;
|
|
60
|
+
const rows = rollOver(state.rows);
|
|
61
61
|
if (provider === "anthropic") {
|
|
62
62
|
// The pool's figure, not whichever account happens to be listed first.
|
|
63
63
|
const pool = combinedWindow(rows, "5h", state.accounts, Date.now(), true);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import { hasTruecolor, levelColor } from "../../ui/format.ts";
|
|
3
|
+
import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The `/provider` picker.
|
|
@@ -79,50 +80,6 @@ function labelFor(theme: any, row: ProviderRow): string {
|
|
|
79
80
|
return `${dot} ${name}`;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
* The chrome pi uses for `/model` and its other in-chat pickers.
|
|
84
|
-
*
|
|
85
|
-
* Not a box: a full-width accent rule, a bold title, the body, then a closing
|
|
86
|
-
* rule. Reproduced from pi's own `frame(theme, title, body, footer)` helper so
|
|
87
|
-
* this reads as part of the chat flow rather than as a floating dialog.
|
|
88
|
-
*
|
|
89
|
-
* Input and mouse events pass straight through, so the frame is presentation
|
|
90
|
-
* only and does not disturb the in-place updates.
|
|
91
|
-
*/
|
|
92
|
-
function framed(theme: any, list: any, title: string): Component {
|
|
93
|
-
const rule = (width: number) => theme.fg("accent", "─".repeat(Math.max(1, width)));
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
invalidate: () => list.invalidate?.(),
|
|
97
|
-
handleInput: (data: string) => list.handleInput(data),
|
|
98
|
-
handleMouse: (event: any) => list.handleMouse?.(event),
|
|
99
|
-
render(width: number): string[] {
|
|
100
|
-
const inner = Math.max(1, width);
|
|
101
|
-
// pi pads title and footer by one column; the list renders flush.
|
|
102
|
-
return [
|
|
103
|
-
rule(inner),
|
|
104
|
-
` ${theme.fg("accent", theme.bold(title))}`,
|
|
105
|
-
...list.render(inner),
|
|
106
|
-
rule(inner),
|
|
107
|
-
];
|
|
108
|
-
},
|
|
109
|
-
} as Component;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Theme for the picker. Every callback takes `selected` so the highlighted row
|
|
114
|
-
* can be emphasised without the caller tracking cursor position.
|
|
115
|
-
*/
|
|
116
|
-
function pickerTheme(theme: any) {
|
|
117
|
-
return {
|
|
118
|
-
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
|
119
|
-
value: (text: string, _selected: boolean) => text,
|
|
120
|
-
description: (text: string) => theme.fg("dim", text),
|
|
121
|
-
cursor: theme.fg("accent", "›"),
|
|
122
|
-
hint: (text: string) => theme.fg("dim", text),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
83
|
export interface PickerDeps {
|
|
127
84
|
/** Re-reads the current rows, so the picker never shows stale state. */
|
|
128
85
|
rows: () => Promise<ProviderRow[]>;
|
|
@@ -195,13 +152,13 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
|
|
|
195
152
|
list = new SettingsList(
|
|
196
153
|
items,
|
|
197
154
|
12,
|
|
198
|
-
|
|
155
|
+
settingsTheme(theme),
|
|
199
156
|
onChange,
|
|
200
157
|
() => done(undefined),
|
|
201
158
|
{ enableSearch: false },
|
|
202
159
|
);
|
|
203
160
|
|
|
204
|
-
return
|
|
161
|
+
return frameSettings(theme, list, "Providers") as Component & { dispose?(): void };
|
|
205
162
|
});
|
|
206
163
|
// No `overlay` option: the picker renders inline in the chat flow rather than
|
|
207
164
|
// floating over it.
|
|
@@ -131,6 +131,17 @@ async function inspect(ctx: any): Promise<Feature[]> {
|
|
|
131
131
|
open: "/remote setup",
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
/* Claude app remote control (preference only; never connect from the hub). */
|
|
135
|
+
const claudeRemote = env("PI_CLAUDE_REMOTE") === "1";
|
|
136
|
+
features.push({
|
|
137
|
+
name: "Claude Remote",
|
|
138
|
+
ready: claudeRemote,
|
|
139
|
+
detail: claudeRemote ? "auto-start enabled for interactive sessions" : "opt-in Claude app mirror; requires Anthropic OAuth",
|
|
140
|
+
commands: ["/claude-remote", "/claude-remote on", "/claude-remote off"],
|
|
141
|
+
setup: "/claude-remote",
|
|
142
|
+
open: "/claude-remote",
|
|
143
|
+
});
|
|
144
|
+
|
|
134
145
|
return features;
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -166,7 +177,7 @@ function buildBrief(features: Feature[]): string {
|
|
|
166
177
|
`Config file: ${configPath()}${existsSync(configPath()) ? "" : " (not created yet)"}`,
|
|
167
178
|
"",
|
|
168
179
|
"Write the reply yourself, in chat. Requirements:",
|
|
169
|
-
"1. One short sentence on what pi-plus is:
|
|
180
|
+
"1. One short sentence on what pi-plus is: extensions for subscriptions, models, workflows, and remote collaboration.",
|
|
170
181
|
"2. A compact list of the capabilities, each with one line on what it does and the command to try. Mark which are already working.",
|
|
171
182
|
pending.length > 0
|
|
172
183
|
? `3. Then a short 'Set these up next' section covering ONLY the ones marked NOT SET UP (${pending.map((f) => f.name).join(", ")}), each with the single command to run and one line on what it will ask for.`
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { WorkflowProgressEvent } from "./types.ts";
|
|
3
3
|
import type { AgentChatMessage, AgentChatRole, AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
4
|
-
import {
|
|
4
|
+
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
5
5
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
6
|
-
import {
|
|
6
|
+
import type { WorkflowStatusCounts } from "./ui/workflow-format.ts";
|
|
7
7
|
import { toDisplayLine, toDisplayText } from "./ui/display-text.ts";
|
|
8
8
|
import { renderWorkflowWidgetLines } from "./ui/workflow-widget.ts";
|
|
9
9
|
|
|
@@ -61,7 +61,7 @@ const WIDGET_REFRESH_INTERVAL_MS = 1_000;
|
|
|
61
61
|
export const DEFAULT_LANE_ITEM_LIMIT = 200;
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Tracks live workflow state for widgets,
|
|
64
|
+
* Tracks live workflow state for widgets, result renderers,
|
|
65
65
|
* and headless stderr breadcrumbs.
|
|
66
66
|
*/
|
|
67
67
|
export class ProgressTracker {
|
|
@@ -81,17 +81,24 @@ export class ProgressTracker {
|
|
|
81
81
|
private doneAt: number | undefined;
|
|
82
82
|
private currentPhase = "Workflow";
|
|
83
83
|
private nextAgentId = 1;
|
|
84
|
-
private lastStatusText: string | undefined;
|
|
85
84
|
private usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
86
85
|
private widgetRefreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
87
86
|
private readonly surfaceKey: string;
|
|
87
|
+
private readonly ctx: ExtensionContext;
|
|
88
|
+
private readonly title: string;
|
|
89
|
+
private readonly runId: string;
|
|
90
|
+
private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
|
|
88
91
|
|
|
89
92
|
constructor(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
ctx: ExtensionContext,
|
|
94
|
+
title: string,
|
|
95
|
+
runId: string,
|
|
96
|
+
onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
|
|
94
97
|
) {
|
|
98
|
+
this.ctx = ctx;
|
|
99
|
+
this.title = title;
|
|
100
|
+
this.runId = runId;
|
|
101
|
+
this.onSnapshot = onSnapshot;
|
|
95
102
|
this.surfaceKey = `workflow:${runId}`;
|
|
96
103
|
this.ensurePhase(this.currentPhase);
|
|
97
104
|
}
|
|
@@ -336,7 +343,6 @@ export class ProgressTracker {
|
|
|
336
343
|
if (!this.ctx.hasUI) return;
|
|
337
344
|
this.publishWidget();
|
|
338
345
|
this.startWidgetRefresh();
|
|
339
|
-
this.publishStatus();
|
|
340
346
|
}
|
|
341
347
|
|
|
342
348
|
private publishWidget(): void {
|
|
@@ -358,24 +364,6 @@ export class ProgressTracker {
|
|
|
358
364
|
this.widgetRefreshInterval = undefined;
|
|
359
365
|
}
|
|
360
366
|
|
|
361
|
-
private publishStatus(): void {
|
|
362
|
-
const status = statusTextFromCounts(
|
|
363
|
-
{
|
|
364
|
-
title: this.title,
|
|
365
|
-
doneAt: this.doneAt,
|
|
366
|
-
currentPhase: this.currentPhase,
|
|
367
|
-
counters: [...this.counters.values()].map((counter) => ({ ...counter })),
|
|
368
|
-
},
|
|
369
|
-
this.statusCountsSnapshot(),
|
|
370
|
-
this.ctx.ui.theme,
|
|
371
|
-
);
|
|
372
|
-
const usage = formatWorkflowUsageLine(this.usageSnapshot);
|
|
373
|
-
const next = usage ? `${status} · ${usage}` : status;
|
|
374
|
-
if (next === this.lastStatusText) return;
|
|
375
|
-
this.ctx.ui.setStatus(this.surfaceKey, next);
|
|
376
|
-
this.lastStatusText = next;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
367
|
/** Clear this run's live workflow surfaces. Final feedback is delivered by the result surface. */
|
|
380
368
|
done(): void {
|
|
381
369
|
this.doneAt = Date.now();
|
|
@@ -384,7 +372,6 @@ export class ProgressTracker {
|
|
|
384
372
|
if (!this.ctx.hasUI) return;
|
|
385
373
|
this.ctx.ui.setWidget(this.surfaceKey, undefined);
|
|
386
374
|
this.ctx.ui.setStatus(this.surfaceKey, undefined);
|
|
387
|
-
this.lastStatusText = undefined;
|
|
388
375
|
}
|
|
389
376
|
|
|
390
377
|
private publishSnapshot(): void {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { truncateToWidth, type Component, type SettingsListTheme } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Shared inline chrome for provider-style SettingsList pickers. */
|
|
4
|
+
export function frameSettings(theme: any, list: Component, title: string): Component {
|
|
5
|
+
const rule = (width: number) => theme.fg("accent", "─".repeat(width));
|
|
6
|
+
return {
|
|
7
|
+
invalidate: () => list.invalidate(),
|
|
8
|
+
handleInput: (data) => list.handleInput?.(data),
|
|
9
|
+
handleMouse: (event) => list.handleMouse?.(event),
|
|
10
|
+
render(width) {
|
|
11
|
+
const inner = Math.max(1, width);
|
|
12
|
+
return [rule(inner), truncateToWidth(` ${theme.fg("accent", theme.bold(title))}`, inner, ""),
|
|
13
|
+
...list.render(inner), rule(inner)];
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function settingsTheme(theme: any): SettingsListTheme {
|
|
19
|
+
return {
|
|
20
|
+
label: (text, selected) => selected ? theme.fg("accent", text) : text,
|
|
21
|
+
value: (text) => text,
|
|
22
|
+
description: (text) => theme.fg("dim", text),
|
|
23
|
+
cursor: theme.fg("accent", "›"),
|
|
24
|
+
hint: (text) => theme.fg("dim", text),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/ui/usage-bars.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
OBSERVED_PROVIDERS,
|
|
9
9
|
poolAvailability,
|
|
10
10
|
pooledWindow,
|
|
11
|
+
rollOver,
|
|
11
12
|
scopedLabels,
|
|
12
13
|
type UsageRow,
|
|
13
14
|
} from "../core/quota/pool.ts";
|
|
@@ -163,7 +164,9 @@ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: numbe
|
|
|
163
164
|
const label = theme.fg(cell.active ? "accent" : "muted", cell.label.slice(0, labelWidth).padEnd(labelWidth));
|
|
164
165
|
const reset = formatShortReset(cell.resetAt);
|
|
165
166
|
const resetWidth = 4;
|
|
166
|
-
|
|
167
|
+
// label, space, bar, space, 5-wide percent, space, reset: exactly cellWidth,
|
|
168
|
+
// so every row's right column starts under its title.
|
|
169
|
+
const barWidth = Math.max(4, cellWidth - labelWidth - resetWidth - 8);
|
|
167
170
|
|
|
168
171
|
if (cell.remaining === undefined) {
|
|
169
172
|
return `${label} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
|
|
@@ -188,7 +191,8 @@ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: numbe
|
|
|
188
191
|
return `${label} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
export function renderUsageLines(
|
|
194
|
+
export function renderUsageLines(raw: UsageState, theme: any, width: number, active?: ActiveModel): string[] {
|
|
195
|
+
const state = { ...raw, rows: rollOver(raw.rows) };
|
|
192
196
|
if (state.loading) return [theme.fg("dim", " usage: loading…")];
|
|
193
197
|
if (state.rows.length === 0) {
|
|
194
198
|
if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
|
|
@@ -219,7 +223,8 @@ export function renderUsageLines(state: UsageState, theme: any, width: number, a
|
|
|
219
223
|
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
220
224
|
}
|
|
221
225
|
|
|
222
|
-
export function usageSummaryText(
|
|
226
|
+
export function usageSummaryText(raw: UsageState): string {
|
|
227
|
+
const state = { ...raw, rows: rollOver(raw.rows) };
|
|
223
228
|
const combined = ["5h", "7d", ...scopedLabels(state.rows)].map((label) => {
|
|
224
229
|
const pool = combinedWindow(state.rows, label, state.accounts, Date.now(), true);
|
|
225
230
|
return `Claude combined ${label}: ${pool
|