osdy-pi 1.6.0 → 1.7.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 +5 -3
- package/extensions/osdy-pi/todo-widget.test.ts +120 -22
- package/extensions/osdy-pi/todo-widget.ts +33 -6
- package/package.json +6 -7
package/README.md
CHANGED
|
@@ -132,13 +132,15 @@ The exclusions prevent Gentle's todo, questionnaire, and agents extensions from
|
|
|
132
132
|
|
|
133
133
|
The model-facing `todo` tool supports `create`, `update`, `list`, `get`, `delete`, and `clear`. Tool results carry full task snapshots; Pi's current session branch is the TODO authority. Session switches and compaction replay the latest valid branch snapshot, rather than sharing one project-wide list. `/todos` opens a read-only, grouped modal (pending, in progress, completed) in interactive Pi; scroll with arrow or Page Up/Down keys and close with Esc or q. The refreshed modal adds a completed-task progress meter, theme-aware status groups and a scroll-position footer while keeping every task reachable. It is not an interactive Markdown editor. In non-terminal modes with UI support, it retains notification output. ODD's `odd/tasks/*.md` ledger and Engram are separate orchestration records: there is no automatic sync with these Pi TODOs.
|
|
134
134
|
|
|
135
|
-
In interactive Pi, the persistent widget appears above the editor while visible tasks exist.
|
|
135
|
+
In interactive Pi, the persistent widget appears above the editor while visible tasks exist. Its compact preview shows up to three task rows (excluding heading, overflow hint, and spacer), prioritizes unfinished tasks on overflow, and crosses out completed subjects. In fullscreen Pi, left-click the heading to expand or return to preview; wheel over the expanded widget to move one task at a time beneath the fixed heading. The final task can reach the first task row even in a short dock. A heading-only or hidden dock cannot display tasks; use `/todos` for the full keyboard-accessible list. Regular mode stays compact; Pi's tool-output expansion does not change this widget. `ctrl+shift+t` retains the separate fully collapsed view.
|
|
136
|
+
|
|
137
|
+
Expansion and scroll position survive same-session refresh, tree navigation, and compaction, with offsets clamped as tasks change. Foreground session replacement and shutdown reset the view; background sessions do not affect it. Task data and `/todos` are unchanged.
|
|
136
138
|
|
|
137
139
|
### Try session TODO
|
|
138
140
|
|
|
139
|
-
Use
|
|
141
|
+
Use this checkout (or a package containing the three-task preview), ask the agent to track a task with `todo`, then run `/todos`. The modal shows the entire grouped list with arrow/Page Up/Down scrolling. `maxWidgetLines: 7` permits five task rows in the expanded fullscreen widget; the preview remains capped at three. Smaller budgets also constrain the preview. Fullscreen widget mouse handling uses Pi's public 0.99.1 component APIs.
|
|
140
142
|
|
|
141
|
-
**Verification to date (not complete):** Prior feature checks passed 187 extension tests, 60 script tests, typecheck, and lint. A disposable Pi TUI with seeded tasks showed the modal's progress meter, grouped rows, scroll footer, and close behavior. Authenticated RPC turns exercised all six `todo` actions, and an authenticated TUI smoke observed widget and `/todos` updates. An RPC lifecycle check observed an empty new session and the completed task on switching back. Compaction replay remains unverified because Pi returned “Nothing to compact (session too small).” Language switching in the authenticated TODO flow remains unverified (an isolated TUI with both extensions did switch English to Spanish). Visual strikethrough of completed rows in the authenticated TUI remains unverified. Launch under an installed Osdy account profile remains unverified. These checks do not establish full feature parity or validate a published npm artifact.
|
|
143
|
+
**Verification to date (not complete):** The user confirmed fullscreen appearance and behavior via `pi:dev`. Automated PTY interaction passed 27 assertions, but fixture cleanup failed and the final task snapshot remains unverified; this was not a full end-to-end pass. Prior feature checks passed 187 extension tests, 60 script tests, typecheck, and lint. A disposable Pi TUI with seeded tasks showed the modal's progress meter, grouped rows, scroll footer, and close behavior. Authenticated RPC turns exercised all six `todo` actions, and an authenticated TUI smoke observed widget and `/todos` updates. An RPC lifecycle check observed an empty new session and the completed task on switching back. Compaction replay remains unverified because Pi returned “Nothing to compact (session too small).” Language switching in the authenticated TODO flow remains unverified (an isolated TUI with both extensions did switch English to Spanish). Visual strikethrough of completed rows in the authenticated TUI remains unverified. Launch under an installed Osdy account profile remains unverified. These checks do not establish full feature parity or validate a published npm artifact.
|
|
142
144
|
|
|
143
145
|
### TODO configuration and language
|
|
144
146
|
|
|
@@ -5,6 +5,7 @@ import test from "node:test";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { Component, TuiMouseEvent, TuiMode } from "@earendil-works/pi-tui";
|
|
8
9
|
import type { TodoConfig } from "./todo-config.js";
|
|
9
10
|
import type { TodoI18n } from "./todo-i18n.js";
|
|
10
11
|
|
|
@@ -25,11 +26,11 @@ registerHooks({
|
|
|
25
26
|
const { registerTodoWidget } = await import("./todo-widget.js");
|
|
26
27
|
const { createTodoSessionStore } = await import("./todo-session.js");
|
|
27
28
|
const { applyTodo } = await import("./todo-domain.js");
|
|
28
|
-
const { visibleWidth } = await import("@earendil-works/pi-tui");
|
|
29
|
+
const { visibleWidth, Container } = await import("@earendil-works/pi-tui");
|
|
29
30
|
const theme = { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~${text}~` };
|
|
30
31
|
function harness(config: () => TodoConfig = () => ({}), i18n?: TodoI18n) {
|
|
31
32
|
type Context = { hasUI: boolean; ui: typeof ui; sessionManager: { getSessionId(): string } };
|
|
32
|
-
type Factory = (tui: { requestRender(force?: boolean): void }, theme: Theme) =>
|
|
33
|
+
type Factory = (tui: { mode: TuiMode; requestRender(force?: boolean): void }, theme: Theme) => Component;
|
|
33
34
|
const handlers = new Map<string, ((event: { toolName?: string }, ctx: Context) => void)[]>();
|
|
34
35
|
const shortcuts = new Map<string, (ctx: Context) => void>();
|
|
35
36
|
const widgets: Array<{ key: string; factory: Factory | undefined; options: { placement: string } | undefined }> = [];
|
|
@@ -42,8 +43,27 @@ function harness(config: () => TodoConfig = () => ({}), i18n?: TodoI18n) {
|
|
|
42
43
|
const emit = (name: string, context: Context, event: { toolName?: string } = {}) => { for (const handler of handlers.get(name) ?? []) handler(event, context); };
|
|
43
44
|
const state = (id: string, tasks: { id: number; subject: string; status: "pending" | "in_progress" | "completed" | "deleted"; blockedBy?: number[]; activeForm?: string }[]) => store.set(id, { tasks, nextId: Math.max(1, ...tasks.map((t) => t.id + 1)) });
|
|
44
45
|
const action = (id: string, kind: "create" | "clear", subject?: string) => store.set(id, applyTodo(store.get(id), kind, subject === undefined ? {} : { subject }).state);
|
|
45
|
-
const
|
|
46
|
-
|
|
46
|
+
const host = { mode: "fullscreen" as TuiMode, requestRender(force?: boolean) { renders.push(force === true); } };
|
|
47
|
+
let factory: Factory | undefined;
|
|
48
|
+
let component: Component | undefined;
|
|
49
|
+
const current = () => {
|
|
50
|
+
if (factory !== widgets.at(-1)?.factory) {
|
|
51
|
+
factory = widgets.at(-1)?.factory;
|
|
52
|
+
component = factory?.(host, theme as Theme);
|
|
53
|
+
}
|
|
54
|
+
return component;
|
|
55
|
+
};
|
|
56
|
+
const lines = (width = 80): string[] => current()?.render(width) ?? [];
|
|
57
|
+
const mouse = (change: Partial<TuiMouseEvent> = {}, clippedHeight?: number) => {
|
|
58
|
+
const child = current();
|
|
59
|
+
if (!child) return undefined;
|
|
60
|
+
const container = new Container(); container.addChild(child);
|
|
61
|
+
const rendered = container.render(80);
|
|
62
|
+
return container.handleMouse({ type: "click", button: "left", x: 0, y: 0,
|
|
63
|
+
screenX: 0, screenY: 0, width: 80, height: clippedHeight ?? rendered.length,
|
|
64
|
+
shift: false, alt: false, ctrl: false, ...change });
|
|
65
|
+
};
|
|
66
|
+
return { ui, host, widgets, renders, shortcuts, ctx, emit, state, action, lines, mouse, store };
|
|
47
67
|
}
|
|
48
68
|
const task = (id: number, status: "pending" | "in_progress" | "completed" | "deleted" = "pending", extra: { activeForm?: string; blockedBy?: number[] } = {}) => ({ id, status, subject: `Task ${id}`, ...extra });
|
|
49
69
|
|
|
@@ -102,24 +122,23 @@ void test("deleted tasks remain excluded while completed and pending tasks persi
|
|
|
102
122
|
assert.deepEqual(h.lines(), []);
|
|
103
123
|
});
|
|
104
124
|
|
|
105
|
-
void test("default widget shows
|
|
125
|
+
void test("default widget shows three task rows at the six-task boundary, with a truthful hint", () => {
|
|
106
126
|
const h = harness(); const c = h.ctx("main"); h.emit("session_start", c);
|
|
107
127
|
h.state("main", Array.from({ length: 6 }, (_, i) => task(i + 1)));
|
|
108
128
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
109
|
-
assert.deepEqual(h.lines(), ["● Todos (0/6)", ...Array.from({ length:
|
|
110
|
-
h.ui.expanded = true;
|
|
111
|
-
assert.equal(h.lines().length,
|
|
112
|
-
assert.equal(h.lines()[6], "└─ ○ Task 6");
|
|
129
|
+
assert.deepEqual(h.lines(), ["● Todos (0/6)", ...Array.from({ length: 3 }, (_, i) => `├─ ○ Task ${i + 1}`), "└─ +3 more (3 pending)", ""]);
|
|
130
|
+
h.ui.expanded = true; // Pi's tool expansion is not the widget's display preference.
|
|
131
|
+
assert.equal(h.lines().length, 6);
|
|
113
132
|
});
|
|
114
133
|
|
|
115
|
-
void test("
|
|
134
|
+
void test("three or fewer preview rows retain completed subjects and completed overflow counts", () => {
|
|
116
135
|
const h = harness(); const c = h.ctx("main"); h.emit("session_start", c);
|
|
117
|
-
h.state("main", [task(1, "completed"),
|
|
136
|
+
h.state("main", [task(1, "completed"), task(2), task(3)]);
|
|
118
137
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
119
|
-
assert.deepEqual(h.lines(), ["● Todos (1/
|
|
138
|
+
assert.deepEqual(h.lines(), ["● Todos (1/3)", "├─ ✓ ~Task 1~", "├─ ○ Task 2", "└─ ○ Task 3", ""]);
|
|
120
139
|
h.state("main", [task(1, "completed"), ...Array.from({ length: 5 }, (_, i) => task(i + 2, "completed"))]);
|
|
121
|
-
assert.equal(h.lines().length,
|
|
122
|
-
assert.equal(h.lines().at(-2), "└─ +
|
|
140
|
+
assert.equal(h.lines().length, 6);
|
|
141
|
+
assert.equal(h.lines().at(-2), "└─ +3 more (3 completed)");
|
|
123
142
|
assert.match(h.lines()[1] ?? "", /~Task 1~/);
|
|
124
143
|
});
|
|
125
144
|
|
|
@@ -127,15 +146,16 @@ void test("default widget retains completed strikethrough when overflow contains
|
|
|
127
146
|
const h = harness(); const c = h.ctx("main"); h.emit("session_start", c);
|
|
128
147
|
h.state("main", [task(1, "completed"), ...Array.from({ length: 14 }, (_, i) => task(i + 2))]);
|
|
129
148
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
130
|
-
assert.deepEqual(h.lines(), ["● Todos (1/15)", ...Array.from({ length:
|
|
149
|
+
assert.deepEqual(h.lines(), ["● Todos (1/15)", ...Array.from({ length: 3 }, (_, i) => `├─ ○ Task ${i + 2}`), "└─ +12 more (1 completed, 11 pending)", ""]);
|
|
131
150
|
h.ui.expanded = true;
|
|
132
|
-
assert.equal(h.lines().length,
|
|
151
|
+
assert.equal(h.lines().length, 6);
|
|
152
|
+
h.mouse();
|
|
133
153
|
assert.equal(h.lines()[1], "├─ ✓ ~Task 1~");
|
|
134
154
|
});
|
|
135
155
|
|
|
136
156
|
void test("configured widget budget preserves small and boundary lists without hiding overflow", () => {
|
|
137
157
|
for (const maxWidgetLines of [3, 7.5, 8]) {
|
|
138
|
-
const slots = Math.floor(maxWidgetLines - 2);
|
|
158
|
+
const slots = Math.min(3, Math.floor(maxWidgetLines - 2));
|
|
139
159
|
const h = harness(() => ({ maxWidgetLines })); const c = h.ctx("main"); h.emit("session_start", c);
|
|
140
160
|
h.state("main", Array.from({ length: slots }, (_, i) => task(i + 1)));
|
|
141
161
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
@@ -147,12 +167,12 @@ void test("configured widget budget preserves small and boundary lists without h
|
|
|
147
167
|
}
|
|
148
168
|
});
|
|
149
169
|
|
|
150
|
-
void test("overflow drops completed first and
|
|
170
|
+
void test("overflow drops completed first and ignores Pi tool expansion", () => {
|
|
151
171
|
const h = harness(); const c = h.ctx("main"); h.emit("session_start", c);
|
|
152
172
|
h.state("main", Array.from({ length: 15 }, (_, i) => task(i + 1, i % 3 === 0 ? "completed" : "pending")));
|
|
153
173
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
154
|
-
assert.equal(h.lines().length,
|
|
155
|
-
h.ui.expanded = true; assert.equal(h.lines().length,
|
|
174
|
+
assert.equal(h.lines().length, 6); assert.match(h.lines()[4] ?? "", /\+12 more \(5 completed, 7 pending\)/);
|
|
175
|
+
h.ui.expanded = true; assert.equal(h.lines().length, 6);
|
|
156
176
|
});
|
|
157
177
|
|
|
158
178
|
void test("branch lifecycle retains completed rows and rebinds replacement UI safely", () => {
|
|
@@ -174,7 +194,7 @@ void test("overflow unfinished tail reports pending and hides completed first",
|
|
|
174
194
|
const h = harness(); const c = h.ctx("main"); h.emit("session_start", c);
|
|
175
195
|
h.state("main", [task(1, "completed"), ...Array.from({ length: 14 }, (_, i) => task(i + 2))]);
|
|
176
196
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
177
|
-
assert.match(h.lines()[
|
|
197
|
+
assert.match(h.lines()[4] ?? "", /\+12 more \(1 completed, 11 pending\)/);
|
|
178
198
|
assert.doesNotMatch(h.lines().join(" "), /Task 1(?!\d)/);
|
|
179
199
|
});
|
|
180
200
|
|
|
@@ -185,7 +205,7 @@ void test("configured budget changes each render while key binds once and hint f
|
|
|
185
205
|
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
186
206
|
assert.equal(h.lines().length, 4);
|
|
187
207
|
config = { maxWidgetLines: 8, collapseKey: "ctrl+g" };
|
|
188
|
-
assert.equal(h.lines().length,
|
|
208
|
+
assert.equal(h.lines().length, 6);
|
|
189
209
|
assert.equal(h.shortcuts.has("ctrl+g"), false);
|
|
190
210
|
h.shortcuts.get("alt+f5")?.(c);
|
|
191
211
|
assert.match(h.lines()[1] ?? "", /ctrl\+g to expand/);
|
|
@@ -224,6 +244,84 @@ void test("widget reads live translations for heading, overflow, statuses and co
|
|
|
224
244
|
assert.equal(h.lines()[1], "└─ ctrl+shift+t to expand");
|
|
225
245
|
});
|
|
226
246
|
|
|
247
|
+
void test("fullscreen header alone toggles bounded expansion; regular mode stays compact", () => {
|
|
248
|
+
const h = harness(); const c = h.ctx("main");
|
|
249
|
+
h.state("main", Array.from({ length: 8 }, (_, i) => task(i + 1)));
|
|
250
|
+
h.emit("session_start", c);
|
|
251
|
+
const original = structuredClone(h.store.get("main"));
|
|
252
|
+
assert.equal(h.mouse({ button: "right" }), undefined);
|
|
253
|
+
assert.equal(h.mouse({ y: 1 }), undefined);
|
|
254
|
+
assert.equal(h.mouse({ type: "press" }), undefined);
|
|
255
|
+
assert.equal(h.mouse({ type: "wheel", wheelDelta: 1 }), undefined);
|
|
256
|
+
h.host.mode = "regular";
|
|
257
|
+
assert.equal(h.mouse(), undefined);
|
|
258
|
+
assert.equal(h.lines().length, 6);
|
|
259
|
+
h.host.mode = "fullscreen";
|
|
260
|
+
assert.equal(h.mouse()?.handled, true);
|
|
261
|
+
assert.equal(h.lines().length, 8); // bounded to five tasks, not all eight
|
|
262
|
+
assert.equal(h.mouse()?.handled, true);
|
|
263
|
+
assert.equal(h.lines().length, 6);
|
|
264
|
+
assert.deepEqual(h.store.get("main"), original);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
void test("real Container intrinsic-height dispatch reaches the last task with only one dock task row", () => {
|
|
268
|
+
const h = harness(); const c = h.ctx("main");
|
|
269
|
+
h.state("main", Array.from({ length: 9 }, (_, i) => task(i + 1)));
|
|
270
|
+
h.emit("session_start", c); h.mouse({}, 2);
|
|
271
|
+
const original = structuredClone(h.store.get("main"));
|
|
272
|
+
for (let id = 1; id <= 9; id++) {
|
|
273
|
+
const clippedDock = h.lines().slice(0, 2);
|
|
274
|
+
assert.equal(clippedDock[0], "● Todos (0/9)");
|
|
275
|
+
assert.match(clippedDock[1] ?? "", new RegExp(`Task ${id}$`));
|
|
276
|
+
const result = h.mouse({ type: "wheel", button: "none", y: 1, wheelDelta: 7 }, 2);
|
|
277
|
+
assert.equal(result?.handled, true);
|
|
278
|
+
assert.ok((result?.target.height ?? 0) > 2); // Container forwards intrinsic height
|
|
279
|
+
}
|
|
280
|
+
assert.match(h.lines()[1] ?? "", /Task 9$/);
|
|
281
|
+
assert.equal(h.mouse({ type: "wheel", wheelDelta: 0 }, 2)?.handled, true);
|
|
282
|
+
assert.equal(h.mouse({ type: "wheel", wheelDelta: Number.NaN }, 2)?.handled, true);
|
|
283
|
+
assert.match(h.lines()[1] ?? "", /Task 9$/);
|
|
284
|
+
assert.equal(h.mouse({ type: "wheel", wheelDelta: -3 }, 2)?.handled, true);
|
|
285
|
+
assert.match(h.lines()[1] ?? "", /Task 8$/);
|
|
286
|
+
h.host.mode = "regular";
|
|
287
|
+
assert.equal(h.mouse({ type: "wheel", wheelDelta: 1 }), undefined);
|
|
288
|
+
assert.equal(h.lines().length, 6);
|
|
289
|
+
h.host.mode = "fullscreen";
|
|
290
|
+
assert.match(h.lines()[1] ?? "", /Task 8$/);
|
|
291
|
+
assert.deepEqual(h.store.get("main"), original);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
void test("expanded offsets survive refresh and tree, clamp on deletion and reset on replacement/shutdown", () => {
|
|
295
|
+
const h = harness(); const c = h.ctx("main");
|
|
296
|
+
h.state("main", Array.from({ length: 7 }, (_, i) => task(i + 1)));
|
|
297
|
+
h.emit("session_start", c); h.mouse();
|
|
298
|
+
for (let i = 0; i < 6; i++) h.mouse({ type: "wheel", wheelDelta: 1 });
|
|
299
|
+
h.renders.length = 0;
|
|
300
|
+
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
301
|
+
assert.deepEqual(h.renders, [false]);
|
|
302
|
+
assert.match(h.lines()[1] ?? "", /Task 7$/);
|
|
303
|
+
h.emit("session_tree", c); h.emit("session_compact", c);
|
|
304
|
+
assert.match(h.lines()[1] ?? "", /Task 7$/);
|
|
305
|
+
h.state("main", [task(1, "completed"), task(2, "deleted"), task(3, "in_progress")]);
|
|
306
|
+
h.emit("tool_execution_end", c, { toolName: "todo" });
|
|
307
|
+
assert.equal(h.lines()[0], "● Todos (1/2)");
|
|
308
|
+
assert.match(h.lines()[1] ?? "", /Task 3$/);
|
|
309
|
+
assert.match(h.lines().at(-2) ?? "", /\+1 more \(1 completed\)/);
|
|
310
|
+
h.shortcuts.get("ctrl+shift+t")?.(c);
|
|
311
|
+
assert.match(h.lines()[1] ?? "", /to expand/);
|
|
312
|
+
assert.equal(h.mouse({ type: "wheel", wheelDelta: 1 }), undefined);
|
|
313
|
+
h.shortcuts.get("ctrl+shift+t")?.(c);
|
|
314
|
+
assert.match(h.lines()[1] ?? "", /Task 3$/);
|
|
315
|
+
const next = h.ctx("next"); // same foreground UI denotes session replacement
|
|
316
|
+
h.state("next", Array.from({ length: 7 }, (_, i) => task(i + 1)));
|
|
317
|
+
h.emit("session_start", next);
|
|
318
|
+
assert.equal(h.lines().length, 6);
|
|
319
|
+
h.mouse(); assert.match(h.lines()[1] ?? "", /Task 1$/);
|
|
320
|
+
h.emit("session_shutdown", c); assert.notEqual(h.widgets.at(-1)?.factory, undefined);
|
|
321
|
+
h.emit("session_shutdown", next); assert.equal(h.widgets.at(-1)?.factory, undefined);
|
|
322
|
+
h.emit("session_start", next); assert.equal(h.lines().length, 6);
|
|
323
|
+
});
|
|
324
|
+
|
|
227
325
|
void test("headless and child sessions do not bind, refresh, collapse or dispose foreground", () => {
|
|
228
326
|
const h = harness(); const main = h.ctx("main"), child = h.ctx("child", true, { ...h.ui });
|
|
229
327
|
h.emit("session_start", h.ctx("headless", false)); h.state("headless", [task(1)]); h.emit("tool_execution_end", h.ctx("headless", false), { toolName: "todo" }); assert.equal(h.widgets.length, 0);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Widget behavior adapted independently from @juicesharp/rpiv-todo@2.11.0 (MIT); see root LICENSE.
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateToWidth, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
4
4
|
import { COLLAPSE_KEY_OFF, getMaxWidgetLines, loadTodoConfig, resolveCollapseKey, type TodoConfig } from "./todo-config.js";
|
|
5
5
|
import type { TodoTask } from "./todo-domain.js";
|
|
6
6
|
import { todoI18n, type TodoI18n } from "./todo-i18n.js";
|
|
@@ -42,6 +42,9 @@ export function registerTodoWidget(pi: ExtensionAPI, store: Store, config: () =>
|
|
|
42
42
|
let mounted = false;
|
|
43
43
|
let tui: TUI | undefined;
|
|
44
44
|
let collapsed = false;
|
|
45
|
+
let expanded = false;
|
|
46
|
+
let offset = 0;
|
|
47
|
+
const clampOffset = (length: number) => { offset = Math.max(0, Math.min(offset, length - 1)); };
|
|
45
48
|
const visible = () => store.get(foreground ?? "").tasks.filter((task) => task.status !== "deleted");
|
|
46
49
|
const render = (theme: Theme, width: number): string[] => {
|
|
47
50
|
const tasks = visible();
|
|
@@ -55,8 +58,14 @@ export function registerTodoWidget(pi: ExtensionAPI, store: Store, config: () =>
|
|
|
55
58
|
const hint = key === COLLAPSE_KEY_OFF ? i18n.t("overlay.collapsed", "collapsed") : i18n.t("overlay.expandHint", "{key} to expand").replace("{key}", key);
|
|
56
59
|
return [heading, trunc(`${theme.fg("dim", "└─")} ${theme.fg("dim", hint)}`), ""];
|
|
57
60
|
}
|
|
58
|
-
|
|
59
|
-
const
|
|
61
|
+
clampOffset(tasks.length);
|
|
62
|
+
const slots = Math.max(1, Math.floor(getMaxWidgetLines(config()) - 2));
|
|
63
|
+
const viewport = expanded && tui?.mode === "fullscreen";
|
|
64
|
+
const rows = viewport ? tasks.slice(offset, offset + slots) : layout(tasks, Math.min(3, slots) + 1).rows;
|
|
65
|
+
const kept = new Set(rows);
|
|
66
|
+
const omitted = tasks.filter((task) => !kept.has(task));
|
|
67
|
+
const completed = omitted.filter((task) => task.status === "completed").length;
|
|
68
|
+
const pending = omitted.length - completed;
|
|
60
69
|
const ids = tasks.some((task) => (task.blockedBy?.length ?? 0) > 0);
|
|
61
70
|
const lines = [heading];
|
|
62
71
|
for (const task of rows) {
|
|
@@ -72,14 +81,29 @@ export function registerTodoWidget(pi: ExtensionAPI, store: Store, config: () =>
|
|
|
72
81
|
lines.push("");
|
|
73
82
|
return lines;
|
|
74
83
|
};
|
|
84
|
+
const handleMouse = (event: TuiMouseEvent): TuiMouseEventResult | undefined => {
|
|
85
|
+
if (tui?.mode !== "fullscreen" || !visible().length) return undefined;
|
|
86
|
+
if (event.type === "click" && event.button === "left" && event.y === 0) {
|
|
87
|
+
collapsed = false; expanded = !expanded; offset = 0;
|
|
88
|
+
} else if (event.type === "wheel" && expanded && !collapsed) {
|
|
89
|
+
// Container supplies intrinsic height, not the clipped dock height. Move one
|
|
90
|
+
// task per event, allowing the final task to reach the first row below heading.
|
|
91
|
+
const delta = event.wheelDelta ?? 0;
|
|
92
|
+
if (Number.isFinite(delta)) offset += Math.sign(delta);
|
|
93
|
+
clampOffset(visible().length);
|
|
94
|
+
} else return undefined;
|
|
95
|
+
tui.requestRender();
|
|
96
|
+
return { handled: true };
|
|
97
|
+
};
|
|
75
98
|
const refresh = () => {
|
|
99
|
+
clampOffset(visible().length);
|
|
76
100
|
if (!ui) return;
|
|
77
101
|
if (!visible().length) {
|
|
78
102
|
if (mounted) { ui.setWidget(KEY, undefined); mounted = false; tui = undefined; }
|
|
79
103
|
} else if (!mounted) {
|
|
80
104
|
ui.setWidget(KEY, (host, theme) => {
|
|
81
105
|
tui = host;
|
|
82
|
-
return { render: (width: number) => render(ui?.theme ?? theme, width), invalidate() {} };
|
|
106
|
+
return { render: (width: number) => render(ui?.theme ?? theme, width), handleMouse, invalidate() {} };
|
|
83
107
|
}, { placement: "aboveEditor" });
|
|
84
108
|
mounted = true;
|
|
85
109
|
} else tui?.requestRender();
|
|
@@ -89,7 +113,10 @@ export function registerTodoWidget(pi: ExtensionAPI, store: Store, config: () =>
|
|
|
89
113
|
pi.on("session_start", (_event, ctx) => {
|
|
90
114
|
if (!ctx.hasUI) return;
|
|
91
115
|
const session = id(ctx);
|
|
92
|
-
|
|
116
|
+
// A different session on another UI is a background child. The foreground
|
|
117
|
+
// UI reused for a new session is replacement; tree/compact keep preference.
|
|
118
|
+
if (foreground !== undefined && foreground !== session && ctx.ui !== ui) return;
|
|
119
|
+
if (foreground !== session) { collapsed = false; expanded = false; offset = 0; }
|
|
93
120
|
if (ui && ui !== ctx.ui && mounted) ui.setWidget(KEY, undefined);
|
|
94
121
|
if (ui !== ctx.ui) { mounted = false; tui = undefined; }
|
|
95
122
|
foreground = session; ui = ctx.ui; refresh();
|
|
@@ -99,7 +126,7 @@ export function registerTodoWidget(pi: ExtensionAPI, store: Store, config: () =>
|
|
|
99
126
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
100
127
|
if (!foregroundEvent(ctx)) return;
|
|
101
128
|
if (mounted) ui?.setWidget(KEY, undefined);
|
|
102
|
-
mounted = false; tui = undefined; ui = undefined; foreground = undefined; collapsed = false;
|
|
129
|
+
mounted = false; tui = undefined; ui = undefined; foreground = undefined; collapsed = false; expanded = false; offset = 0;
|
|
103
130
|
});
|
|
104
131
|
pi.on("tool_execution_end", (event, ctx) => {
|
|
105
132
|
if (event.toolName === "todo" && event.isError !== true && foregroundEvent(ctx)) refresh();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osdy-pi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Osdy Pi package: themes, header, and custom editor for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@earendil-works/pi-coding-agent": "*",
|
|
58
58
|
"@earendil-works/pi-tui": "*",
|
|
59
|
-
"@juicesharp/rpiv-i18n": "*"
|
|
59
|
+
"@juicesharp/rpiv-i18n": "*",
|
|
60
|
+
"typebox": "*"
|
|
60
61
|
},
|
|
61
62
|
"peerDependenciesMeta": {
|
|
62
63
|
"@juicesharp/rpiv-i18n": {
|
|
@@ -64,15 +65,13 @@
|
|
|
64
65
|
}
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
67
|
-
"@earendil-works/pi-coding-agent": "
|
|
68
|
-
"@earendil-works/pi-tui": "
|
|
68
|
+
"@earendil-works/pi-coding-agent": "0.99.1",
|
|
69
|
+
"@earendil-works/pi-tui": "0.99.1",
|
|
69
70
|
"@eslint/js": "^9.39.1",
|
|
70
71
|
"@types/node": "^25.8.0",
|
|
71
72
|
"eslint": "^9.39.1",
|
|
73
|
+
"typebox": "^1.3.7",
|
|
72
74
|
"typescript": "^5.9.3",
|
|
73
75
|
"typescript-eslint": "^8.46.3"
|
|
74
|
-
},
|
|
75
|
-
"dependencies": {
|
|
76
|
-
"typebox": "^1.3.7"
|
|
77
76
|
}
|
|
78
77
|
}
|