gentle-pi 2.4.0 → 2.5.0
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 +170 -12
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/orchestrator-delegation.md +19 -9
- package/assets/orchestrator.md +5 -5
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +898 -79
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +88 -12
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-integration-v2.ts +58 -8
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +87 -11
- package/runtime/review-integration-v2.mjs +58 -8
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +6 -2
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +28 -12
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +17 -6
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +3 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +25 -0
- package/tests/review-host-relay-routing.test.ts +20 -4
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { CARD_TONE, renderCard, type CardTheme } from "./shell-card.ts";
|
|
2
|
+
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Todo: the task list the model keeps while it works. Everything here
|
|
5
|
+
// is pure. The state lives in the session branch (every tool result carries
|
|
6
|
+
// the full snapshot), and staleness is how many turns passed since the last
|
|
7
|
+
// write while tasks are still open.
|
|
8
|
+
|
|
9
|
+
export const TODO_STATUS = {
|
|
10
|
+
PENDING: "pending",
|
|
11
|
+
IN_PROGRESS: "in_progress",
|
|
12
|
+
DONE: "done",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type TodoStatus = (typeof TODO_STATUS)[keyof typeof TODO_STATUS];
|
|
16
|
+
|
|
17
|
+
export const TODO_ACTION = {
|
|
18
|
+
WRITE: "write",
|
|
19
|
+
ADD: "add",
|
|
20
|
+
UPDATE: "update",
|
|
21
|
+
CLEAR: "clear",
|
|
22
|
+
LIST: "list",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type TodoAction = (typeof TODO_ACTION)[keyof typeof TODO_ACTION];
|
|
26
|
+
|
|
27
|
+
export interface TodoTask {
|
|
28
|
+
id: number;
|
|
29
|
+
title: string;
|
|
30
|
+
status: TodoStatus;
|
|
31
|
+
note?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TodoState {
|
|
35
|
+
tasks: TodoTask[];
|
|
36
|
+
nextId: number;
|
|
37
|
+
/** Turn index of the last write, or null before the first one. */
|
|
38
|
+
updatedTurn: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TodoTaskInput {
|
|
42
|
+
id?: number;
|
|
43
|
+
title?: string;
|
|
44
|
+
status?: string;
|
|
45
|
+
note?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TodoParams {
|
|
49
|
+
action: string;
|
|
50
|
+
tasks?: TodoTaskInput[];
|
|
51
|
+
id?: number;
|
|
52
|
+
title?: string;
|
|
53
|
+
status?: string;
|
|
54
|
+
note?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TodoResult {
|
|
58
|
+
state: TodoState;
|
|
59
|
+
text: string;
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TodoSummary {
|
|
64
|
+
done: number;
|
|
65
|
+
total: number;
|
|
66
|
+
open: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TodoTheme extends CardTheme {
|
|
70
|
+
strikethrough(text: string): string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TodoRenderOptions {
|
|
74
|
+
collapsed: boolean;
|
|
75
|
+
staleTurns: number;
|
|
76
|
+
collapseKey?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tool results carry the snapshot under this key; the old rpiv-todo shape is read too. */
|
|
80
|
+
export const TODO_DETAILS_KEY = "gentleTodo";
|
|
81
|
+
export const TODO_TOOL_NAME = "todo";
|
|
82
|
+
export const TODO_GLYPH = "❀";
|
|
83
|
+
const STATUS_ALIASES: Record<string, TodoStatus> = { completed: TODO_STATUS.DONE, complete: TODO_STATUS.DONE, doing: TODO_STATUS.IN_PROGRESS, todo: TODO_STATUS.PENDING };
|
|
84
|
+
const STATUS_GLYPH: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.DONE]: "✓" };
|
|
85
|
+
const STATUS_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "dim" };
|
|
86
|
+
const GLYPH_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "success" };
|
|
87
|
+
const NOTE_ROLE = "muted";
|
|
88
|
+
const STALE_AFTER_TURNS = 2;
|
|
89
|
+
/** Above this many rows the finished tasks fold into one line and the rest is capped. */
|
|
90
|
+
const ROW_CAP = 12;
|
|
91
|
+
|
|
92
|
+
export function emptyTodo(): TodoState {
|
|
93
|
+
return { tasks: [], nextId: 1, updatedTurn: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cleanText(value: unknown): string {
|
|
97
|
+
return typeof value === "string" ? sanitizeTerminalText(value).replace(/\s+/g, " ").trim() : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseStatus(value: unknown): TodoStatus | undefined {
|
|
101
|
+
if (value === undefined || value === null || value === "") return TODO_STATUS.PENDING;
|
|
102
|
+
if (typeof value !== "string") return undefined;
|
|
103
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
104
|
+
if ((Object.values(TODO_STATUS) as string[]).includes(normalized)) return normalized as TodoStatus;
|
|
105
|
+
return STATUS_ALIASES[normalized];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function todoSummary(state: TodoState): TodoSummary {
|
|
109
|
+
const done = state.tasks.filter((task) => task.status === TODO_STATUS.DONE).length;
|
|
110
|
+
return { done, total: state.tasks.length, open: state.tasks.length - done };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function staleTurns(state: TodoState, currentTurn: number): number {
|
|
114
|
+
if (state.updatedTurn === null || todoSummary(state).open === 0) return 0;
|
|
115
|
+
return Math.max(0, currentTurn - state.updatedTurn);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function summaryText(state: TodoState): string {
|
|
119
|
+
const { done, total } = todoSummary(state);
|
|
120
|
+
const inProgress = state.tasks.filter((task) => task.status === TODO_STATUS.IN_PROGRESS).length;
|
|
121
|
+
return `${total} ${total === 1 ? "task" : "tasks"} · ${done} done · ${inProgress} in progress`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function taskLine(task: TodoTask): string {
|
|
125
|
+
return `[${task.status}] #${task.id} ${task.title}${task.note ? ` — ${task.note}` : ""}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildTask(input: TodoTaskInput, id: number): TodoTask | string {
|
|
129
|
+
const title = cleanText(input.title);
|
|
130
|
+
if (title.length === 0) return "title is required";
|
|
131
|
+
const status = parseStatus(input.status);
|
|
132
|
+
if (status === undefined) return `unknown status "${input.status}" (use pending, in_progress, or done)`;
|
|
133
|
+
const note = cleanText(input.note);
|
|
134
|
+
return { id, title, status, ...(note ? { note } : {}) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function write(state: TodoState, inputs: TodoTaskInput[], turn: number): TodoResult {
|
|
138
|
+
const tasks: TodoTask[] = [];
|
|
139
|
+
let nextId = state.nextId;
|
|
140
|
+
for (const input of inputs) {
|
|
141
|
+
const keepId = typeof input.id === "number" && Number.isInteger(input.id) && input.id > 0 && !tasks.some((task) => task.id === input.id);
|
|
142
|
+
const id = keepId ? (input.id as number) : nextId++;
|
|
143
|
+
nextId = Math.max(nextId, id + 1);
|
|
144
|
+
const built = buildTask(input, id);
|
|
145
|
+
if (typeof built === "string") return { state, text: `Error: ${built}`, error: built };
|
|
146
|
+
tasks.push(built);
|
|
147
|
+
}
|
|
148
|
+
const next = { tasks, nextId, updatedTurn: turn };
|
|
149
|
+
return { state: next, text: `Todo list written: ${summaryText(next)}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function update(state: TodoState, params: TodoParams, turn: number): TodoResult {
|
|
153
|
+
const task = state.tasks.find((candidate) => candidate.id === params.id);
|
|
154
|
+
if (!task) {
|
|
155
|
+
const error = `no task #${params.id}`;
|
|
156
|
+
return { state, text: `Error: ${error}`, error };
|
|
157
|
+
}
|
|
158
|
+
if (params.status === undefined && params.title === undefined && params.note === undefined) {
|
|
159
|
+
const error = "nothing to update: pass status, title, or note";
|
|
160
|
+
return { state, text: `Error: ${error}`, error };
|
|
161
|
+
}
|
|
162
|
+
const built = buildTask({ id: task.id, title: params.title ?? task.title, status: params.status ?? task.status, note: params.note ?? task.note }, task.id);
|
|
163
|
+
if (typeof built === "string") return { state, text: `Error: ${built}`, error: built };
|
|
164
|
+
const next = { ...state, tasks: state.tasks.map((candidate) => (candidate.id === task.id ? built : candidate)), updatedTurn: turn };
|
|
165
|
+
const change = task.status === built.status ? "updated" : `→ ${built.status}`;
|
|
166
|
+
return { state: next, text: `#${built.id} ${built.title} ${change}` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function applyTodo(state: TodoState, params: TodoParams, turn: number): TodoResult {
|
|
170
|
+
switch (params.action) {
|
|
171
|
+
case TODO_ACTION.WRITE:
|
|
172
|
+
return write(state, Array.isArray(params.tasks) ? params.tasks : [], turn);
|
|
173
|
+
case TODO_ACTION.ADD: {
|
|
174
|
+
const built = buildTask({ title: params.title, status: params.status, note: params.note }, state.nextId);
|
|
175
|
+
if (typeof built === "string") return { state, text: `Error: ${built}`, error: built };
|
|
176
|
+
const next = { tasks: [...state.tasks, built], nextId: state.nextId + 1, updatedTurn: turn };
|
|
177
|
+
return { state: next, text: `Added #${built.id} ${built.title} (${built.status})` };
|
|
178
|
+
}
|
|
179
|
+
case TODO_ACTION.UPDATE:
|
|
180
|
+
return update(state, params, turn);
|
|
181
|
+
case TODO_ACTION.CLEAR:
|
|
182
|
+
return { state: { ...emptyTodo(), updatedTurn: turn }, text: `Cleared ${state.tasks.length} ${state.tasks.length === 1 ? "task" : "tasks"}` };
|
|
183
|
+
case TODO_ACTION.LIST:
|
|
184
|
+
return { state, text: state.tasks.length === 0 ? "No tasks." : state.tasks.map(taskLine).join("\n") };
|
|
185
|
+
default: {
|
|
186
|
+
const error = `unknown action "${params.action}" (use write, add, update, clear, or list)`;
|
|
187
|
+
return { state, text: `Error: ${error}`, error };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface ReplayEntry {
|
|
193
|
+
type?: string;
|
|
194
|
+
message?: { role?: string; toolName?: string; isError?: boolean; details?: unknown };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface RpivTask {
|
|
198
|
+
id?: number;
|
|
199
|
+
subject?: string;
|
|
200
|
+
status?: string;
|
|
201
|
+
activeForm?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fromRpiv(details: { tasks?: RpivTask[]; nextId?: number }): TodoState {
|
|
205
|
+
const tasks: TodoTask[] = [];
|
|
206
|
+
for (const raw of details.tasks ?? []) {
|
|
207
|
+
if (raw.status === "deleted" || typeof raw.id !== "number") continue;
|
|
208
|
+
const built = buildTask({ id: raw.id, title: raw.subject, status: raw.status, note: raw.status === TODO_STATUS.IN_PROGRESS ? raw.activeForm : undefined }, raw.id);
|
|
209
|
+
if (typeof built !== "string") tasks.push(built);
|
|
210
|
+
}
|
|
211
|
+
return { tasks, nextId: typeof details.nextId === "number" ? details.nextId : tasks.reduce((max, task) => Math.max(max, task.id), 0) + 1, updatedTurn: null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The last successful `todo` result on the branch is the state: ours carries
|
|
215
|
+
// the snapshot under `gentleTodo`; rpiv-todo carried `tasks` + `nextId` at the top.
|
|
216
|
+
export function replayTodo(entries: readonly unknown[]): TodoState {
|
|
217
|
+
let state = emptyTodo();
|
|
218
|
+
for (const entry of entries as ReplayEntry[]) {
|
|
219
|
+
const message = entry?.message;
|
|
220
|
+
if (entry?.type !== "message" || message?.role !== "toolResult" || message.toolName !== TODO_TOOL_NAME || message.isError === true) continue;
|
|
221
|
+
const details = message.details as Record<string, unknown> | undefined;
|
|
222
|
+
if (!details || typeof details !== "object") continue;
|
|
223
|
+
const ours = details[TODO_DETAILS_KEY] as TodoState | undefined;
|
|
224
|
+
if (ours && Array.isArray(ours.tasks)) state = { tasks: ours.tasks, nextId: ours.nextId, updatedTurn: ours.updatedTurn ?? null };
|
|
225
|
+
else if (Array.isArray(details.tasks)) state = fromRpiv(details as { tasks?: RpivTask[]; nextId?: number });
|
|
226
|
+
}
|
|
227
|
+
return state;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function todoPromptBlock(state: TodoState, stale: number): string | undefined {
|
|
231
|
+
if (todoSummary(state).open === 0) return undefined;
|
|
232
|
+
const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
|
|
233
|
+
const staleLine = stale >= STALE_AFTER_TURNS ? `\n(stale: ${stale} turns without an update — bring the list up to date now)` : "";
|
|
234
|
+
return [
|
|
235
|
+
"## Todo list",
|
|
236
|
+
"Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
|
|
237
|
+
...lines,
|
|
238
|
+
].join("\n") + staleLine;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function taskRow(task: TodoTask, theme: TodoTheme): string {
|
|
242
|
+
const title = task.status === TODO_STATUS.DONE ? theme.strikethrough(task.title) : task.title;
|
|
243
|
+
const note = task.status === TODO_STATUS.IN_PROGRESS && task.note ? ` ${theme.fg(NOTE_ROLE, "·")} ${theme.fg(NOTE_ROLE, task.note)}` : "";
|
|
244
|
+
return `${theme.fg(GLYPH_ROLE[task.status], STATUS_GLYPH[task.status])} ${theme.fg(STATUS_ROLE[task.status], title)}${note}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Long lists keep the card short: done tasks fold into "✓ N done" and the
|
|
248
|
+
// open ones fill the remaining rows, with a trailing count for the rest.
|
|
249
|
+
function bodyRows(state: TodoState, theme: TodoTheme): string[] {
|
|
250
|
+
if (state.tasks.length <= ROW_CAP) return state.tasks.map((task) => taskRow(task, theme));
|
|
251
|
+
const { done } = todoSummary(state);
|
|
252
|
+
const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE);
|
|
253
|
+
const rows: string[] = [];
|
|
254
|
+
if (done > 0) rows.push(`${theme.fg(GLYPH_ROLE[TODO_STATUS.DONE], STATUS_GLYPH[TODO_STATUS.DONE])} ${theme.fg(NOTE_ROLE, `${done} done`)}`);
|
|
255
|
+
const room = ROW_CAP - rows.length - (open.length > ROW_CAP - rows.length ? 1 : 0);
|
|
256
|
+
for (const task of open.slice(0, room)) rows.push(taskRow(task, theme));
|
|
257
|
+
if (open.length > room) rows.push(theme.fg(NOTE_ROLE, `… ${open.length - room} more`));
|
|
258
|
+
return rows;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function collapsedRow(state: TodoState, theme: TodoTheme): string {
|
|
262
|
+
const active = state.tasks.find((task) => task.status === TODO_STATUS.IN_PROGRESS);
|
|
263
|
+
if (active) return taskRow(active, theme);
|
|
264
|
+
const { open } = todoSummary(state);
|
|
265
|
+
return `${theme.fg(GLYPH_ROLE[TODO_STATUS.PENDING], STATUS_GLYPH[TODO_STATUS.PENDING])} ${theme.fg(NOTE_ROLE, `${open} open`)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number, options: TodoRenderOptions): string[] {
|
|
269
|
+
if (state.tasks.length === 0) return [];
|
|
270
|
+
const { done, total } = todoSummary(state);
|
|
271
|
+
const stale = options.staleTurns >= STALE_AFTER_TURNS;
|
|
272
|
+
const hint = stale ? `stale · ${options.staleTurns} turns` : options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : undefined;
|
|
273
|
+
const body = options.collapsed ? [collapsedRow(state, theme)] : bodyRows(state, theme);
|
|
274
|
+
return renderCard(
|
|
275
|
+
{ title: "Todos", subtitle: `${done} of ${total}`, body, tone: stale ? CARD_TONE.WARNING : CARD_TONE.INFO, glyph: TODO_GLYPH },
|
|
276
|
+
theme,
|
|
277
|
+
width,
|
|
278
|
+
{ expanded: true, hint },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { renderUsagePanel, type ActiveProvider, type UsageStore, type UsageTheme } from "./shell-usage.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Shell subscriptions overlay: a framed panel over the usage store.
|
|
5
|
+
// It reads the store on every render, so a refresh only needs to record.
|
|
6
|
+
|
|
7
|
+
export interface UsageViewDeps {
|
|
8
|
+
theme: UsageTheme;
|
|
9
|
+
now(): number;
|
|
10
|
+
active(): ActiveProvider | undefined;
|
|
11
|
+
onRefresh(): Promise<void>;
|
|
12
|
+
onClose(): void;
|
|
13
|
+
requestRender(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TITLE = "✿ Subscriptions";
|
|
17
|
+
const REFRESHING = "✿ Subscriptions · refreshing…";
|
|
18
|
+
const FRAME_ROLE = "border";
|
|
19
|
+
const TITLE_ROLE = "customMessageLabel";
|
|
20
|
+
const KEY_ROLE = "accent";
|
|
21
|
+
const KEY_TEXT_ROLE = "dim";
|
|
22
|
+
const KEYS = [
|
|
23
|
+
["r", "refresh"],
|
|
24
|
+
["esc", "close"],
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
function rule(length: number): string {
|
|
28
|
+
return "─".repeat(Math.max(0, length));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fit(text: string, width: number): string {
|
|
32
|
+
const clipped = truncateToWidth(text, width, "…");
|
|
33
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class UsageView {
|
|
37
|
+
private readonly store: UsageStore;
|
|
38
|
+
private readonly deps: UsageViewDeps;
|
|
39
|
+
private refreshing = false;
|
|
40
|
+
|
|
41
|
+
constructor(store: UsageStore, deps: UsageViewDeps) {
|
|
42
|
+
this.store = store;
|
|
43
|
+
this.deps = deps;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
handleInput(data: string): void {
|
|
47
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
48
|
+
this.deps.onClose();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (data === "r" && !this.refreshing) {
|
|
52
|
+
this.refreshing = true;
|
|
53
|
+
this.deps.requestRender();
|
|
54
|
+
void this.deps.onRefresh().finally(() => {
|
|
55
|
+
this.refreshing = false;
|
|
56
|
+
this.deps.requestRender();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
const theme = this.deps.theme;
|
|
63
|
+
const inner = width - 2;
|
|
64
|
+
const title = this.refreshing ? REFRESHING : TITLE;
|
|
65
|
+
const top = theme.fg(FRAME_ROLE, "╭─ ") + theme.fg(TITLE_ROLE, title) + theme.fg(FRAME_ROLE, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
|
|
66
|
+
const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active()).map(
|
|
67
|
+
(line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
|
|
68
|
+
);
|
|
69
|
+
const keys = KEYS.map(([key, label]) => `${theme.fg(KEY_ROLE, key)} ${theme.fg(KEY_TEXT_ROLE, label)}`).join(" ");
|
|
70
|
+
const keysLine = `${theme.fg(FRAME_ROLE, "│")} ${fit(keys, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`;
|
|
71
|
+
const bottom = theme.fg(FRAME_ROLE, `╰${rule(inner)}╯`);
|
|
72
|
+
return [top, ...body, keysLine, bottom];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
invalidate(): void {}
|
|
76
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { paintGauge } from "./shell-gauge.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Shell subscription usage: the rate-limit windows each connected
|
|
5
|
+
// provider reports. Codex sends them as SSE headers and through its usage
|
|
6
|
+
// endpoint; both land in the same model. Parsing is pure and never keeps
|
|
7
|
+
// account details beyond the plan name.
|
|
8
|
+
|
|
9
|
+
export interface UsageWindow {
|
|
10
|
+
label: string;
|
|
11
|
+
usedPercent: number;
|
|
12
|
+
windowSeconds: number;
|
|
13
|
+
resetAt: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UsageLimit {
|
|
17
|
+
name: string;
|
|
18
|
+
windows: UsageWindow[];
|
|
19
|
+
limitReached: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProviderUsage {
|
|
23
|
+
provider: string;
|
|
24
|
+
plan: string | undefined;
|
|
25
|
+
limits: UsageLimit[];
|
|
26
|
+
fetchedAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UsageTheme {
|
|
30
|
+
fg(color: string, text: string): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface RawWindow {
|
|
34
|
+
used_percent?: number;
|
|
35
|
+
limit_window_seconds?: number;
|
|
36
|
+
reset_after_seconds?: number;
|
|
37
|
+
reset_at?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RawRateLimit {
|
|
41
|
+
limit_reached?: boolean;
|
|
42
|
+
primary_window?: RawWindow | null;
|
|
43
|
+
secondary_window?: RawWindow | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RawAdditionalLimit {
|
|
47
|
+
limit_name?: string;
|
|
48
|
+
rate_limit?: RawRateLimit | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RawCodexUsage {
|
|
52
|
+
plan_type?: string;
|
|
53
|
+
rate_limit?: RawRateLimit | null;
|
|
54
|
+
additional_rate_limits?: RawAdditionalLimit[] | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const CODEX_PROVIDER = "openai-codex";
|
|
58
|
+
export const ANTHROPIC_PROVIDER = "anthropic";
|
|
59
|
+
const ANTHROPIC_MAIN_LIMIT = "claude";
|
|
60
|
+
const ANTHROPIC_PREFIX = "anthropic-ratelimit-unified-";
|
|
61
|
+
const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
|
|
62
|
+
["5h", 18_000],
|
|
63
|
+
["7d", 604_800],
|
|
64
|
+
];
|
|
65
|
+
export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
66
|
+
const CODEX_MAIN_LIMIT = "codex";
|
|
67
|
+
const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
|
|
68
|
+
const HEADER_PREFIX = "x-codex-";
|
|
69
|
+
const PANEL_METER_CELLS = 16;
|
|
70
|
+
const MINUTE = 60;
|
|
71
|
+
const HOUR = 3600;
|
|
72
|
+
const DAY = 86_400;
|
|
73
|
+
const WEEK = 604_800;
|
|
74
|
+
const ROLE = {
|
|
75
|
+
PROVIDER: "text",
|
|
76
|
+
PLAN: "muted",
|
|
77
|
+
LIMIT: "customMessageLabel",
|
|
78
|
+
LABEL: "muted",
|
|
79
|
+
PERCENT: "text",
|
|
80
|
+
RESET: "dim",
|
|
81
|
+
SEPARATOR: "muted",
|
|
82
|
+
} as const;
|
|
83
|
+
export const USAGE_EMPTY_MESSAGE = "No subscription usage yet. Usage arrives with the next response, or press r to fetch it.";
|
|
84
|
+
export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER];
|
|
85
|
+
const PENDING_NOTE: Record<string, string> = {
|
|
86
|
+
[CODEX_PROVIDER]: "no usage yet · r to fetch",
|
|
87
|
+
[ANTHROPIC_PROVIDER]: "usage arrives with the first response",
|
|
88
|
+
};
|
|
89
|
+
const UNSUPPORTED_NOTE = "no subscription usage for this provider";
|
|
90
|
+
const ACTIVE_MARK = "✿";
|
|
91
|
+
|
|
92
|
+
export interface ActiveProvider {
|
|
93
|
+
provider: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function providerNote(provider: string): string {
|
|
97
|
+
return PENDING_NOTE[provider] ?? UNSUPPORTED_NOTE;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function windowLabel(seconds: number): string {
|
|
101
|
+
if (seconds === WEEK) return "week";
|
|
102
|
+
if (seconds >= DAY && seconds % DAY === 0) return `${seconds / DAY}d`;
|
|
103
|
+
if (seconds >= HOUR && seconds % HOUR === 0) return `${seconds / HOUR}h`;
|
|
104
|
+
return `${Math.round(seconds / MINUTE)}m`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatReset(resetAt: number | null, now: number): string {
|
|
108
|
+
if (resetAt === null) return "";
|
|
109
|
+
const seconds = Math.floor((resetAt - now) / 1000);
|
|
110
|
+
if (seconds <= 0) return "resets now";
|
|
111
|
+
if (seconds < HOUR) return `resets in ${Math.max(1, Math.round(seconds / MINUTE))}m`;
|
|
112
|
+
if (seconds < DAY) return `resets in ${Math.floor(seconds / HOUR)}h ${Math.floor((seconds % HOUR) / MINUTE)}m`;
|
|
113
|
+
return `resets in ${Math.floor(seconds / DAY)}d ${Math.floor((seconds % DAY) / HOUR)}h`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseWindow(raw: RawWindow | null | undefined, now: number): UsageWindow | undefined {
|
|
117
|
+
if (!raw || typeof raw.used_percent !== "number" || typeof raw.limit_window_seconds !== "number") return undefined;
|
|
118
|
+
const resetAt =
|
|
119
|
+
typeof raw.reset_at === "number" ? raw.reset_at * 1000 : typeof raw.reset_after_seconds === "number" ? now + raw.reset_after_seconds * 1000 : null;
|
|
120
|
+
return { label: windowLabel(raw.limit_window_seconds), usedPercent: raw.used_percent, windowSeconds: raw.limit_window_seconds, resetAt };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseRateLimit(name: string, raw: RawRateLimit | null | undefined, now: number): UsageLimit | undefined {
|
|
124
|
+
if (!raw) return undefined;
|
|
125
|
+
const windows = [parseWindow(raw.primary_window, now), parseWindow(raw.secondary_window, now)].filter((window): window is UsageWindow => window !== undefined);
|
|
126
|
+
if (windows.length === 0) return undefined;
|
|
127
|
+
return { name, windows, limitReached: raw.limit_reached === true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseCodexUsage(payload: unknown, now: number): ProviderUsage {
|
|
131
|
+
const raw = (payload ?? {}) as RawCodexUsage;
|
|
132
|
+
const limits: UsageLimit[] = [];
|
|
133
|
+
const main = parseRateLimit(CODEX_MAIN_LIMIT, raw.rate_limit, now);
|
|
134
|
+
if (main) limits.push(main);
|
|
135
|
+
for (const extra of raw.additional_rate_limits ?? []) {
|
|
136
|
+
const limit = parseRateLimit(extra.limit_name ?? "limit", extra.rate_limit, now);
|
|
137
|
+
if (limit) limits.push(limit);
|
|
138
|
+
}
|
|
139
|
+
return { provider: CODEX_PROVIDER, plan: typeof raw.plan_type === "string" ? raw.plan_type : undefined, limits, fetchedAt: now };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function headerWindow(headers: Record<string, string>, kind: "primary" | "secondary", now: number): UsageWindow | undefined {
|
|
143
|
+
const used = Number.parseFloat(headers[`${HEADER_PREFIX}${kind}-used-percent`] ?? "");
|
|
144
|
+
if (!Number.isFinite(used)) return undefined;
|
|
145
|
+
const minutes = Number.parseInt(headers[`${HEADER_PREFIX}${kind}-window-minutes`] ?? "", 10);
|
|
146
|
+
const resetAt = Number.parseInt(headers[`${HEADER_PREFIX}${kind}-reset-at`] ?? "", 10);
|
|
147
|
+
const seconds = Number.isFinite(minutes) ? minutes * MINUTE : 0;
|
|
148
|
+
return { label: windowLabel(seconds), usedPercent: used, windowSeconds: seconds, resetAt: Number.isFinite(resetAt) ? resetAt * 1000 : null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseCodexHeaders(headers: Record<string, string>, now: number): ProviderUsage | undefined {
|
|
152
|
+
const windows = [headerWindow(headers, "primary", now), headerWindow(headers, "secondary", now)].filter((window): window is UsageWindow => window !== undefined);
|
|
153
|
+
if (windows.length === 0) return undefined;
|
|
154
|
+
const reached = headers[`${HEADER_PREFIX}rate-limit-reached-type`];
|
|
155
|
+
return { provider: CODEX_PROVIDER, plan: undefined, limits: [{ name: CODEX_MAIN_LIMIT, windows, limitReached: Boolean(reached) }], fetchedAt: now };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Claude Pro/Max sends utilization as a 0..1 fraction per window and reset
|
|
159
|
+
// times in Unix seconds on every response; there is no usage endpoint.
|
|
160
|
+
export function parseAnthropicHeaders(headers: Record<string, string>, now: number): ProviderUsage | undefined {
|
|
161
|
+
const windows: UsageWindow[] = [];
|
|
162
|
+
for (const [key, seconds] of ANTHROPIC_WINDOWS) {
|
|
163
|
+
const fraction = Number.parseFloat(headers[`${ANTHROPIC_PREFIX}${key}-utilization`] ?? "");
|
|
164
|
+
if (!Number.isFinite(fraction)) continue;
|
|
165
|
+
const reset = Number.parseInt(headers[`${ANTHROPIC_PREFIX}${key}-reset`] ?? "", 10);
|
|
166
|
+
windows.push({ label: windowLabel(seconds), usedPercent: fraction * 100, windowSeconds: seconds, resetAt: Number.isFinite(reset) ? reset * 1000 : null });
|
|
167
|
+
}
|
|
168
|
+
if (windows.length === 0) return undefined;
|
|
169
|
+
const status = headers[`${ANTHROPIC_PREFIX}status`];
|
|
170
|
+
return { provider: ANTHROPIC_PROVIDER, plan: undefined, limits: [{ name: ANTHROPIC_MAIN_LIMIT, windows, limitReached: status === "rejected" }], fetchedAt: now };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function parseUsageHeaders(headers: Record<string, string>, now: number): ProviderUsage | undefined {
|
|
174
|
+
return parseCodexHeaders(headers, now) ?? parseAnthropicHeaders(headers, now);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function accountIdFromToken(token: string): string | undefined {
|
|
178
|
+
const parts = token.split(".");
|
|
179
|
+
if (parts.length !== 3) return undefined;
|
|
180
|
+
try {
|
|
181
|
+
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
182
|
+
const auth = claims[CODEX_ACCOUNT_CLAIM] as { chatgpt_account_id?: unknown } | undefined;
|
|
183
|
+
return typeof auth?.chatgpt_account_id === "string" && auth.chatgpt_account_id.length > 0 ? auth.chatgpt_account_id : undefined;
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function paintMeter(percent: number, cells: number, theme: UsageTheme): string {
|
|
190
|
+
return paintGauge(percent, theme, cells);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme): string | undefined {
|
|
194
|
+
const main = usage.limits[0];
|
|
195
|
+
const [first, ...rest] = main?.windows ?? [];
|
|
196
|
+
if (!first) return undefined;
|
|
197
|
+
const head = `${theme.fg(ROLE.LABEL, main.name)} ${theme.fg(ROLE.LABEL, first.label)} ${paintMeter(first.usedPercent, 8, theme)} ${theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)}`;
|
|
198
|
+
const tail = rest.map((window) => `${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.LABEL, window.label)} ${theme.fg(ROLE.PERCENT, `${Math.round(window.usedPercent)}%`)}`);
|
|
199
|
+
return [head, ...tail].join(" ");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function updatedAgo(fetchedAt: number, now: number): string {
|
|
203
|
+
const minutes = Math.floor((now - fetchedAt) / 60_000);
|
|
204
|
+
return minutes < 1 ? "updated just now" : `updated ${minutes}m ago`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The active provider comes first, marked with the petal, and explains
|
|
208
|
+
// itself when it has no data yet. Other providers seen this session follow.
|
|
209
|
+
export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider): string[] {
|
|
210
|
+
const activeUsage = active ? usages.find((usage) => usage.provider === active.provider) : undefined;
|
|
211
|
+
const others = usages.filter((usage) => usage !== activeUsage);
|
|
212
|
+
if (!active && usages.length === 0) return [truncateToWidth(USAGE_EMPTY_MESSAGE, width, "…")];
|
|
213
|
+
const lines: string[] = [];
|
|
214
|
+
if (active && !activeUsage) {
|
|
215
|
+
lines.push(`${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ${theme.fg(ROLE.PROVIDER, active.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, providerNote(active.provider))}`);
|
|
216
|
+
}
|
|
217
|
+
for (const usage of [...(activeUsage ? [activeUsage] : []), ...others]) {
|
|
218
|
+
const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
|
|
219
|
+
const plan = usage.plan ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.PLAN, usage.plan)}` : "";
|
|
220
|
+
lines.push(`${mark}${theme.fg(ROLE.PROVIDER, usage.provider)}${plan} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, updatedAgo(usage.fetchedAt, now))}`);
|
|
221
|
+
for (const limit of usage.limits) {
|
|
222
|
+
lines.push(` ${theme.fg(ROLE.LIMIT, limit.name)}`);
|
|
223
|
+
for (const window of limit.windows) {
|
|
224
|
+
const percent = `${Math.round(window.usedPercent)}%`.padStart(4);
|
|
225
|
+
lines.push(` ${theme.fg(ROLE.LABEL, window.label.padEnd(5))} ${paintMeter(window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)} ${theme.fg(ROLE.RESET, formatReset(window.resetAt, now))}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return lines.map((line) => truncateToWidth(line, width, "…"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export class UsageStore {
|
|
233
|
+
private readonly usages = new Map<string, ProviderUsage>();
|
|
234
|
+
|
|
235
|
+
record(usage: ProviderUsage): void {
|
|
236
|
+
this.usages.set(usage.provider, usage);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get(provider: string): ProviderUsage | undefined {
|
|
240
|
+
return this.usages.get(provider);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
all(): ProviderUsage[] {
|
|
244
|
+
return [...this.usages.values()];
|
|
245
|
+
}
|
|
246
|
+
}
|