pi-jar 0.1.4 → 0.1.6
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 +35 -19
- package/docs/INTEGRATIONS.md +2 -2
- package/docs/WORKFLOWS.md +34 -10
- package/extensions/index.ts +87 -15
- package/package.json +1 -1
- package/src/activity-view.ts +23 -7
- package/src/ask-tool.ts +12 -7
- package/src/compact-tools.ts +66 -28
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +934 -130
- package/src/democracy.ts +214 -0
- package/src/discussion.ts +189 -0
- package/src/footer.ts +4 -3
- package/src/goal-loop.ts +11 -16
- package/src/model-roles.ts +58 -29
- package/src/plan.ts +4 -10
- package/src/roles-ui.ts +18 -10
- package/src/settings-ui.ts +41 -15
- package/src/settings.ts +9 -1
- package/src/status.ts +2 -1
- package/src/task-tool.ts +33 -18
- package/src/tasks.ts +7 -7
package/src/model-roles.ts
CHANGED
|
@@ -11,7 +11,11 @@ export const BUILTIN_ROLES = [
|
|
|
11
11
|
{ role: "plan", label: "Architect", usedBy: "plan mode" },
|
|
12
12
|
{ role: "implement", label: "Builder", usedBy: "goal implement rounds and approved plans" },
|
|
13
13
|
{ role: "advisor", label: "Advisor", usedBy: "second opinions, stuck-work gates and the goal audit" },
|
|
14
|
-
{ role: "
|
|
14
|
+
{ role: "moderator", label: "Moderator", usedBy: "main-agent multi-agent coordination" },
|
|
15
|
+
{ role: "scout", label: "Scout", usedBy: "cheap delegated discovery and codebase scouting" },
|
|
16
|
+
{ role: "worker", label: "Worker", usedBy: "sandboxed delegated implementation" },
|
|
17
|
+
{ role: "reviewer", label: "Reviewer", usedBy: "forked delegated review and verification" },
|
|
18
|
+
{ role: "task", label: "Subtask", usedBy: "legacy or explicitly generic delegated tasks" },
|
|
15
19
|
{ role: "commit", label: "Commit", usedBy: "/jar commit messages" }
|
|
16
20
|
] as const;
|
|
17
21
|
export const MODEL_ROLES = BUILTIN_ROLES.map((item) => item.role);
|
|
@@ -24,7 +28,7 @@ export interface RoleTag { name?: string; color?: string }
|
|
|
24
28
|
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
|
|
25
29
|
export interface RoleRow {
|
|
26
30
|
role: string; label: string; usedBy?: string; custom: boolean;
|
|
27
|
-
spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
|
|
31
|
+
spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string; fallbacks: string[];
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export const ROLE_FILE = "pi-jar-roles.json";
|
|
@@ -180,6 +184,23 @@ export class ModelRoleManager {
|
|
|
180
184
|
return result && !("error" in result) ? result : undefined;
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
/** Primary role target followed by its configured fallbacks, de-duplicated in priority order. */
|
|
188
|
+
resolveCandidates(role: string): ResolvedRole[] {
|
|
189
|
+
const candidates: ResolvedRole[] = [];
|
|
190
|
+
const primary = this.resolve(role);
|
|
191
|
+
if (primary) candidates.push(primary);
|
|
192
|
+
for (const spec of this.fallbackSpecs(role)) {
|
|
193
|
+
try { candidates.push(this.resolveSpec(spec)); } catch { /* malformed alias targets are skipped */ }
|
|
194
|
+
}
|
|
195
|
+
const seen = new Set<string>();
|
|
196
|
+
return candidates.filter((item) => {
|
|
197
|
+
const key = item.provider + "/" + item.model + ":" + (item.thinking ?? "");
|
|
198
|
+
if (seen.has(key)) return false;
|
|
199
|
+
seen.add(key);
|
|
200
|
+
return true;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
183
204
|
get(role: string): RoleAssignment | undefined {
|
|
184
205
|
const resolved = this.resolve(role);
|
|
185
206
|
if (!resolved) return undefined;
|
|
@@ -196,7 +217,7 @@ export class ModelRoleManager {
|
|
|
196
217
|
];
|
|
197
218
|
return rows.map((row) => {
|
|
198
219
|
const result = resolveRole(roles, row.role);
|
|
199
|
-
return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role),
|
|
220
|
+
return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role), fallbacks: this.fallbackSpecs(row.role),
|
|
200
221
|
...(result && "error" in result ? { error: result.error } : result ? { resolved: result } : {}) };
|
|
201
222
|
});
|
|
202
223
|
}
|
|
@@ -219,39 +240,46 @@ export class ModelRoleManager {
|
|
|
219
240
|
}
|
|
220
241
|
|
|
221
242
|
async activate(role: string, ctx: ExtensionContext, quiet = false): Promise<boolean> {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
const primary = resolveRole(this.merged(), role);
|
|
244
|
+
const primaryError = primary && "error" in primary ? primary.error : undefined;
|
|
245
|
+
const candidates = this.resolveCandidates(role);
|
|
246
|
+
if (!candidates.length) {
|
|
247
|
+
if (primaryError) {
|
|
248
|
+
if (!quiet) ctx.ui.notify("Role " + role + ": " + primaryError, "warning");
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
228
251
|
if (!quiet) ctx.ui.notify("Role " + role + " follows the current model; assign one with /roles", "info");
|
|
229
252
|
this.active = role;
|
|
230
253
|
this.status(ctx);
|
|
231
254
|
return true;
|
|
232
255
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (!
|
|
243
|
-
|
|
256
|
+
|
|
257
|
+
const failures: string[] = [];
|
|
258
|
+
for (let index = 0; index < candidates.length; index++) {
|
|
259
|
+
const candidate = candidates[index]!;
|
|
260
|
+
const model = ctx.modelRegistry.find(candidate.provider, candidate.model);
|
|
261
|
+
if (!model) { failures.push(candidate.provider + "/" + candidate.model + " not found"); continue; }
|
|
262
|
+
let changed: boolean;
|
|
263
|
+
this.applying = true;
|
|
264
|
+
try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
|
|
265
|
+
if (!changed) { failures.push(candidate.provider + "/" + candidate.model + " unavailable"); continue; }
|
|
266
|
+
if (candidate.thinking) this.withApplying(() => this.pi.setThinkingLevel(candidate.thinking!));
|
|
267
|
+
this.active = role;
|
|
268
|
+
this.status(ctx);
|
|
269
|
+
if (!quiet) {
|
|
270
|
+
ctx.ui.notify("Role " + role + " · " + candidate.provider + "/" + candidate.model
|
|
271
|
+
+ (candidate.thinking ? " · " + candidate.thinking : "") + (index ? " · fallback " + index : ""), "info");
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
244
274
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (!quiet) ctx.ui.notify("Role " + role + " · " + result.provider + "/" + result.model + (result.thinking ? " · " + result.thinking : ""), "info");
|
|
249
|
-
return true;
|
|
275
|
+
|
|
276
|
+
if (!quiet) ctx.ui.notify("No usable model for role " + role + ": " + failures.join("; "), "warning");
|
|
277
|
+
return false;
|
|
250
278
|
}
|
|
251
279
|
|
|
252
280
|
/** Switch to a role for a bounded workflow; the returned function restores the previous model. */
|
|
253
281
|
async activateTemporary(role: string, ctx: ExtensionContext): Promise<() => Promise<void>> {
|
|
254
|
-
if (!this.
|
|
282
|
+
if (!this.resolveCandidates(role).length) return async () => {};
|
|
255
283
|
const previousModel = ctx.model;
|
|
256
284
|
const previousThinking = this.pi.getThinkingLevel();
|
|
257
285
|
const previousActive = this.active;
|
|
@@ -276,7 +304,7 @@ export class ModelRoleManager {
|
|
|
276
304
|
|
|
277
305
|
/** Activate the next assigned role in the cycle order. */
|
|
278
306
|
async cycle(ctx: ExtensionContext): Promise<string | undefined> {
|
|
279
|
-
const order = this.cycleOrder().filter((role) => this.
|
|
307
|
+
const order = this.cycleOrder().filter((role) => this.resolveCandidates(role).length > 0);
|
|
280
308
|
if (!order.length) { ctx.ui.notify("No roles in the cycle order are assigned; configure them with /roles", "info"); return undefined; }
|
|
281
309
|
const next = order[(order.indexOf(this.active ?? "") + 1) % order.length]!;
|
|
282
310
|
return await this.activate(next, ctx) ? next : undefined;
|
|
@@ -322,7 +350,7 @@ export class ModelRoleManager {
|
|
|
322
350
|
if (scope === "project") this.project = next; else this.global = next;
|
|
323
351
|
}
|
|
324
352
|
|
|
325
|
-
register(openUi?: (ctx: ExtensionContext) => Promise<void
|
|
353
|
+
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
|
|
326
354
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
327
355
|
if (this.applying) return;
|
|
328
356
|
this.manualEpoch++;
|
|
@@ -333,7 +361,8 @@ export class ModelRoleManager {
|
|
|
333
361
|
this.pi.on("session_start", async (_event, ctx) => {
|
|
334
362
|
this.load(ctx.cwd);
|
|
335
363
|
this.active = undefined;
|
|
336
|
-
|
|
364
|
+
// Delegated children already received their role model/effort via CLI arguments.
|
|
365
|
+
if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
|
|
337
366
|
});
|
|
338
367
|
|
|
339
368
|
this.pi.registerCommand("roles", {
|
package/src/plan.ts
CHANGED
|
@@ -75,7 +75,6 @@ export class PlanMode {
|
|
|
75
75
|
private reminders = 0;
|
|
76
76
|
private reviewing = false;
|
|
77
77
|
private compacting = false;
|
|
78
|
-
private contextDirty = true;
|
|
79
78
|
private compactGeneration = 0;
|
|
80
79
|
private executionRestore: (() => Promise<void>) | undefined;
|
|
81
80
|
private executionRunning = false;
|
|
@@ -137,7 +136,6 @@ export class PlanMode {
|
|
|
137
136
|
try { this.ensureDirectory(ctx); }
|
|
138
137
|
catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
|
|
139
138
|
this.enabled = true;
|
|
140
|
-
this.contextDirty = true;
|
|
141
139
|
this.pendingReview = false;
|
|
142
140
|
this.reminders = 0;
|
|
143
141
|
this.toolsBefore = this.pi.getActiveTools();
|
|
@@ -152,7 +150,6 @@ export class PlanMode {
|
|
|
152
150
|
private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
|
|
153
151
|
const wasEnabled = this.enabled;
|
|
154
152
|
this.enabled = false;
|
|
155
|
-
this.contextDirty = true;
|
|
156
153
|
this.compacting = false;
|
|
157
154
|
this.pendingReview = false;
|
|
158
155
|
this.compactGeneration++;
|
|
@@ -417,14 +414,13 @@ export class PlanMode {
|
|
|
417
414
|
|
|
418
415
|
this.pi.on("before_agent_start", async () => {
|
|
419
416
|
if (!this.enabled) return;
|
|
420
|
-
this.contextDirty = true;
|
|
421
417
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
422
418
|
});
|
|
423
419
|
|
|
424
420
|
// Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
|
|
425
|
-
// context and newest reminder; when it is off, remove all stale plan prompt messages.
|
|
421
|
+
// context and newest reminder; when it is off, remove all stale plan prompt messages. Context
|
|
422
|
+
// rewrites apply to a single LLM call, so this runs on every call.
|
|
426
423
|
this.pi.on("context", async (event) => {
|
|
427
|
-
if (!this.contextDirty) return;
|
|
428
424
|
let latestContext = -1;
|
|
429
425
|
let latestReminder = -1;
|
|
430
426
|
let needsPrune = false;
|
|
@@ -438,7 +434,6 @@ export class PlanMode {
|
|
|
438
434
|
else latestReminder = index;
|
|
439
435
|
}
|
|
440
436
|
}
|
|
441
|
-
this.contextDirty = false;
|
|
442
437
|
if (!needsPrune) return;
|
|
443
438
|
return { messages: event.messages.filter((raw, index) => {
|
|
444
439
|
const type = (raw as { customType?: string }).customType;
|
|
@@ -460,7 +455,6 @@ export class PlanMode {
|
|
|
460
455
|
return;
|
|
461
456
|
}
|
|
462
457
|
this.reminders++;
|
|
463
|
-
this.contextDirty = true;
|
|
464
458
|
return {
|
|
465
459
|
entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
|
|
466
460
|
content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
|
|
@@ -480,8 +474,8 @@ export class PlanMode {
|
|
|
480
474
|
if (this.enabled && this.pendingReview) await this.review(ctx);
|
|
481
475
|
});
|
|
482
476
|
|
|
483
|
-
this.pi.on("session_start", async (_event, ctx) => {
|
|
484
|
-
this.pi.on("session_tree", async (_event, ctx) => {
|
|
477
|
+
this.pi.on("session_start", async (_event, ctx) => { await this.restore(sessionBranch(ctx), ctx); });
|
|
478
|
+
this.pi.on("session_tree", async (_event, ctx) => { await this.restore(sessionBranch(ctx), ctx); });
|
|
485
479
|
this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
|
|
486
480
|
}
|
|
487
481
|
}
|
package/src/roles-ui.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { promptText } from "./dialogs.ts";
|
|
|
4
4
|
import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
|
|
5
5
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
6
|
|
|
7
|
-
type RoleAction = "model" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
|
|
7
|
+
type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
|
|
8
8
|
const ACTIONS: { action: RoleAction; key: string; hint: string; label: string }[] = [
|
|
9
9
|
{ action: "activate", key: "\r", hint: "⏎", label: "Activate this role now" },
|
|
10
10
|
{ action: "model", key: "m", hint: "m", label: "Assign a model" },
|
|
11
|
+
{ action: "fallback", key: "f", hint: "f", label: "Set fallback model" },
|
|
11
12
|
{ action: "alias", key: "a", hint: "a", label: "Alias another role (@role)" },
|
|
12
13
|
{ action: "thinking", key: "t", hint: "t", label: "Set thinking effort" },
|
|
13
14
|
{ action: "scope", key: "s", hint: "s", label: "Move between global and project" },
|
|
@@ -28,6 +29,7 @@ function detail(row: RoleRow, active: string | undefined): [ThemeColor, string][
|
|
|
28
29
|
[target[0], "Model " + target[1]],
|
|
29
30
|
["muted", "Alias " + (row.resolved && row.resolved.via.length > 1 ? row.resolved.via.join(" → ") : "—")],
|
|
30
31
|
["muted", "Effort " + (row.resolved?.thinking ?? "follow current")],
|
|
32
|
+
["muted", "Fallback " + (row.fallbacks.length ? row.fallbacks.join(" → ") : "—")],
|
|
31
33
|
["muted", "Scope " + (row.scope ?? "unset") + (row.scope === "project" ? " (.pi/pi-jar-roles.json)" : row.scope === "global" ? " (agent dir)" : "")],
|
|
32
34
|
["dim", ""],
|
|
33
35
|
["dim", "Specs: provider/model[:effort] · @role[:effort] · *"],
|
|
@@ -41,7 +43,6 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
41
43
|
let scroll = 0;
|
|
42
44
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
43
45
|
let width = 80;
|
|
44
|
-
let actionsTop = -1;
|
|
45
46
|
const rows = () => roles.list();
|
|
46
47
|
const finish = (action: RoleAction) => done({ action, index: selected });
|
|
47
48
|
return {
|
|
@@ -67,9 +68,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
67
68
|
if (event.type !== "click" || event.button !== "left") return;
|
|
68
69
|
if (event.y === 0 && event.x >= width - 3) { done(undefined); return { handled: true }; }
|
|
69
70
|
const row = event.y - layout.top;
|
|
70
|
-
// Actions
|
|
71
|
-
const actionIndex =
|
|
72
|
-
if (
|
|
71
|
+
// Actions stay in the footer so even short terminals can click every action.
|
|
72
|
+
const actionIndex = event.y - layout.footerTop;
|
|
73
|
+
if (event.x >= 2 && event.x < width - 2 && actionIndex >= 0 && actionIndex < ACTIONS.length) {
|
|
73
74
|
finish(ACTIONS[actionIndex]!.action); return { handled: true };
|
|
74
75
|
}
|
|
75
76
|
if (row >= 0 && row < layout.rows && (layout.leftWidth === 0 || event.x < layout.leftWidth + 3)) {
|
|
@@ -81,7 +82,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
81
82
|
width = Math.max(24, available);
|
|
82
83
|
const list = rows();
|
|
83
84
|
selected = Math.max(0, Math.min(list.length - 1, selected));
|
|
84
|
-
const height = Math.min(contentRows(
|
|
85
|
+
const height = Math.min(contentRows(ACTIONS.length + 4, 4), Math.max(13, list.length));
|
|
85
86
|
if (selected < scroll) scroll = selected;
|
|
86
87
|
if (selected >= scroll + height) scroll = selected - height + 1;
|
|
87
88
|
const active = roles.activeRole();
|
|
@@ -97,11 +98,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
97
98
|
const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
|
|
98
99
|
const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
|
|
99
100
|
.map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
|
|
100
|
-
|
|
101
|
-
const facts = info.slice(0, -3), help = info.slice(-3);
|
|
102
|
-
const body = [...header, ...facts, theme.fg("accent", "Actions"), ...actions, ...help];
|
|
103
|
-
actionsTop = header.length + facts.length + 1;
|
|
101
|
+
const body = [...header, ...info];
|
|
104
102
|
const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
|
|
103
|
+
...actions,
|
|
105
104
|
theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
|
|
106
105
|
], height);
|
|
107
106
|
layout = split.layout;
|
|
@@ -149,6 +148,15 @@ async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row:
|
|
|
149
148
|
if (selected) roles.update(row.role, selected + effort(), scope);
|
|
150
149
|
return;
|
|
151
150
|
}
|
|
151
|
+
case "fallback": {
|
|
152
|
+
const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
|
|
153
|
+
if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
|
|
154
|
+
const choices = ["Clear fallback", ...models.map((model) => model.provider + "/" + model.id)];
|
|
155
|
+
const selected = await ctx.ui.select("Fallback model for " + row.role, choices);
|
|
156
|
+
if (!selected) return;
|
|
157
|
+
roles.updateFallbacks(row.role, selected === "Clear fallback" ? [] : [selected], scope);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
152
160
|
case "alias": {
|
|
153
161
|
const targets = roles.list().filter((item) => item.role !== row.role).map((item) => "@" + item.role);
|
|
154
162
|
const selected = await ctx.ui.select("Alias " + row.role + " to", targets);
|
package/src/settings-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
4
|
import { ICON_SETS } from "./icons.ts";
|
|
5
|
-
import { GOAL_ROUND_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
5
|
+
import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
6
|
|
|
7
7
|
const LABELS: Record<FooterField, string> = {
|
|
8
8
|
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
|
|
@@ -35,25 +35,37 @@ export async function openJarSettings(
|
|
|
35
35
|
let width = 64;
|
|
36
36
|
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
37
37
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
38
|
-
|
|
38
|
+
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
39
|
+
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
40
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 6 : 7;
|
|
41
|
+
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
|
|
39
42
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
40
|
-
const
|
|
43
|
+
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
44
|
+
const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
|
|
41
45
|
const firstVisible = () => Math.min(Math.max(0, selected - pageSize() + 1), Math.max(0, fields() - pageSize()));
|
|
42
46
|
const visibleCount = () => Math.min(pageSize(), fields());
|
|
43
47
|
const apply = (index: number) => {
|
|
44
48
|
const state = current();
|
|
45
|
-
if (
|
|
49
|
+
if (choosing) {
|
|
50
|
+
if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
|
|
51
|
+
else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
|
|
52
|
+
finishChoice();
|
|
53
|
+
} else if (page === "footer") {
|
|
46
54
|
const field = FOOTER_FIELDS[index];
|
|
47
55
|
if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
|
|
48
56
|
} else if (page === "pi") {
|
|
49
57
|
if (index === 0 && pi) { try { pi.setFullscreen(!pi.get().fullscreen); ctx.ui.notify("Pi TUI mode saved; restart Pi to apply", "info"); } catch (error) { piError(error); } }
|
|
50
58
|
else if (index === 1 && pi) { try { pi.setCopyOnSelect(!pi.get().copyOnSelect); } catch (error) { piError(error); } }
|
|
51
59
|
else if (index === 2) {
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
choosing = "goalRounds";
|
|
61
|
+
selected = Math.max(0, GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never));
|
|
54
62
|
}
|
|
55
|
-
else if (index === 3)
|
|
56
|
-
|
|
63
|
+
else if (index === 3) {
|
|
64
|
+
choosing = "maxSubagents";
|
|
65
|
+
selected = Math.max(0, MAX_SUBAGENT_CHOICES.indexOf(state.maxSubagents));
|
|
66
|
+
}
|
|
67
|
+
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
68
|
+
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
57
69
|
} else if (index === 0) {
|
|
58
70
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
59
71
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -77,7 +89,12 @@ export async function openJarSettings(
|
|
|
77
89
|
return {
|
|
78
90
|
invalidate() {},
|
|
79
91
|
handleInput(data: string) {
|
|
80
|
-
if (matchesKey(data, Key.escape) || data === "q") {
|
|
92
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
93
|
+
if (choosing) { finishChoice(); tui.requestRender(); } else done();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
|
|
97
|
+
|| matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left))) return;
|
|
81
98
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
82
99
|
page = PAGES[(PAGES.indexOf(page) + 1) % PAGES.length]!;
|
|
83
100
|
selected = 0;
|
|
@@ -97,6 +114,7 @@ export async function openJarSettings(
|
|
|
97
114
|
if (event.type !== "click" || event.button !== "left") return;
|
|
98
115
|
if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
|
|
99
116
|
if (event.y === 1) {
|
|
117
|
+
choosing = undefined;
|
|
100
118
|
if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
|
|
101
119
|
else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
|
|
102
120
|
else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
|
|
@@ -121,14 +139,17 @@ export async function openJarSettings(
|
|
|
121
139
|
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]"));
|
|
122
140
|
const prefs = pi?.get();
|
|
123
141
|
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
124
|
-
const allRows =
|
|
142
|
+
const allRows = choosing
|
|
143
|
+
? choices().map((choice, index) => row(index, String(choice), state[choosing!] === choice ? "CURRENT" : "", true))
|
|
144
|
+
: page === "footer"
|
|
125
145
|
? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
|
|
126
146
|
: page === "pi" ? [
|
|
127
147
|
row(0, "Mouse clicks (Pi fullscreen, restart)", !prefs ? "N/A" : prefs.fullscreen ? "ON" : "OFF", !!prefs?.fullscreen),
|
|
128
148
|
row(1, "Copy on select", !prefs ? "N/A" : prefs.copyOnSelect ? "ON" : "OFF", !!prefs?.copyOnSelect),
|
|
129
149
|
row(2, "Goal auto rounds", String(state.goalRounds), true),
|
|
130
|
-
row(3, "
|
|
131
|
-
row(4, "Advisor
|
|
150
|
+
row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
|
|
151
|
+
row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
152
|
+
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
|
|
132
153
|
] : [
|
|
133
154
|
row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
|
|
134
155
|
row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
|
|
@@ -141,11 +162,16 @@ export async function openJarSettings(
|
|
|
141
162
|
const start = firstVisible();
|
|
142
163
|
const rows = allRows.slice(start, start + pageSize());
|
|
143
164
|
return [top, tabs, divider,
|
|
144
|
-
line(theme.fg("accent",
|
|
145
|
-
|
|
165
|
+
line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
|
|
166
|
+
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
|
|
167
|
+
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
168
|
+
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
169
|
+
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
146
170
|
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
|
|
147
171
|
...rows,
|
|
148
|
-
line(theme.fg("dim",
|
|
172
|
+
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
|
173
|
+
: " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
174
|
+
...(explainPool() ? [line(theme.fg("dim", " Lowering does not terminate existing subagents"))] : []),
|
|
149
175
|
theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
|
|
150
176
|
}
|
|
151
177
|
};
|
package/src/settings.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface JarVisualSettings {
|
|
|
17
17
|
suggestions: boolean;
|
|
18
18
|
/** Automatic implement/audit rounds per user message while a goal is active. */
|
|
19
19
|
goalRounds: number;
|
|
20
|
+
/** Retained live subagent pool, including idle/paused agents; lowering does not terminate them. */
|
|
21
|
+
maxSubagents: MaxSubagents;
|
|
20
22
|
/** The jar_advisor second-opinion tool and /advisor. */
|
|
21
23
|
advisor: boolean;
|
|
22
24
|
/** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
|
|
@@ -27,10 +29,15 @@ export interface JarVisualSettings {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
|
|
32
|
+
export const MAX_SUBAGENT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
33
|
+
export type MaxSubagents = (typeof MAX_SUBAGENT_CHOICES)[number];
|
|
34
|
+
export function isMaxSubagents(value: unknown): value is MaxSubagents {
|
|
35
|
+
return MAX_SUBAGENT_CHOICES.some((choice) => choice === value);
|
|
36
|
+
}
|
|
30
37
|
|
|
31
38
|
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
32
39
|
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
33
|
-
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
|
|
40
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
@@ -60,6 +67,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
60
67
|
suggestions: typeof value.suggestions === "boolean" ? value.suggestions : fallback.suggestions,
|
|
61
68
|
goalRounds: typeof value.goalRounds === "number" && Number.isInteger(value.goalRounds) && value.goalRounds >= 1 && value.goalRounds <= 50
|
|
62
69
|
? value.goalRounds : fallback.goalRounds,
|
|
70
|
+
maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
|
|
63
71
|
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
64
72
|
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
65
73
|
icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
|
package/src/status.ts
CHANGED
|
@@ -73,7 +73,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
|
|
|
73
73
|
const id = rawKey.slice(ROLE_PREFIX.length);
|
|
74
74
|
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
|
|
75
75
|
const role = cachedRole(id, rawValue);
|
|
76
|
-
|
|
76
|
+
const ttl = id.startsWith("delegate-") ? 24 * 60 * 60_000 : ROLE_TTL_MS;
|
|
77
|
+
if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ttl) {
|
|
77
78
|
extras.push(`${id}: unavailable`);
|
|
78
79
|
continue;
|
|
79
80
|
}
|
package/src/task-tool.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Type } from "typebox";
|
|
|
4
4
|
import type { Todo, TodoInput, TodoStatus } from "./tasks.ts";
|
|
5
5
|
import { TODO_STATUSES, TodoStore, todoMark, todoProgress, todoTotals } from "./tasks.ts";
|
|
6
6
|
|
|
7
|
-
const ACTIONS = ["write", "list", "append", "remove", "
|
|
7
|
+
const ACTIONS = ["write", "list", "append", "remove", "update", "add", "start", "done", "open", "edit", "delete"] as const;
|
|
8
8
|
const Action = Type.Unsafe<(typeof ACTIONS)[number]>({ type: "string", enum: [...ACTIONS] });
|
|
9
9
|
const Status = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: [...TODO_STATUSES] });
|
|
10
10
|
const Content = Type.String({ description: "Imperative task title, e.g. \"Run the tests\"." });
|
|
@@ -19,10 +19,10 @@ const Parameters = Type.Object({
|
|
|
19
19
|
subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), content: Content, status: Status, activeForm: ActiveForm }), {
|
|
20
20
|
description: "Optional steps of this task (one level deep). The task's status then follows its subtasks."
|
|
21
21
|
}))
|
|
22
|
-
}), { description: "For append: new tasks only. For
|
|
22
|
+
}), { description: "For append: new tasks only. For update: exactly one updated task. With write (or no action): the complete list, replacing it." })),
|
|
23
23
|
action: Type.Optional(Action),
|
|
24
|
-
id: Type.Optional(Type.String({ description: "Stable task id for
|
|
25
|
-
title: Type.Optional(Type.String({ description: "Task title for append/add/edit/
|
|
24
|
+
id: Type.Optional(Type.String({ description: "Stable task id for update/remove/start/done/open/edit/delete. On a parent, start runs its first open subtask; done/open apply to all its subtasks." })),
|
|
25
|
+
title: Type.Optional(Type.String({ description: "Task title for append/add/edit/update." })),
|
|
26
26
|
status: Type.Optional(Status),
|
|
27
27
|
activeForm: ActiveForm,
|
|
28
28
|
parent: Type.Optional(Type.String({ description: "For append/add: id of a top-level task to add the new task under as a subtask." })),
|
|
@@ -42,6 +42,8 @@ interface TaskToolDetails {
|
|
|
42
42
|
changed?: string;
|
|
43
43
|
/** Full stable id index, including items outside the bounded preview. */
|
|
44
44
|
ids?: { id: string; title: string; parentId?: string }[];
|
|
45
|
+
/** Optional deliberately small render surface for terse mutations such as start/done. */
|
|
46
|
+
display?: Todo[];
|
|
45
47
|
truncated?: boolean;
|
|
46
48
|
/** Subtask counts of previewed parents when the preview cuts the list. */
|
|
47
49
|
progress?: { id: string; done: number; total: number }[];
|
|
@@ -85,11 +87,13 @@ export function registerTaskTool(
|
|
|
85
87
|
pi.registerTool({
|
|
86
88
|
name: "jar_todo",
|
|
87
89
|
label: "tasks",
|
|
88
|
-
description: "Maintain session tasks
|
|
89
|
-
promptSnippet: "Track multi-step work with jar_todo:
|
|
90
|
+
description: "Maintain session tasks: write establishes a fresh complete task plan, update changes one existing task by stable id, append adds genuinely new work, and remove deletes one task tree. start/done/open/edit remain concise status operations. Stable IDs are returned for every task.",
|
|
91
|
+
promptSnippet: "Track multi-step work with jar_todo: write the task plan first, update/remove existing tasks by stable id, append only when new work arrives mid-run, and keep one leaf in_progress.",
|
|
90
92
|
promptGuidelines: [
|
|
91
93
|
"Use jar_todo proactively, without waiting for the user, for any task with three or more distinct steps, when the user gives several tasks, or right after receiving new instructions. Skip it for single trivial requests and pure questions.",
|
|
92
|
-
"Prefer
|
|
94
|
+
"Prefer write when establishing the task plan for a new request. If the existing checklist is fully completed, start the next request with a fresh write rather than appending onto old completed work.",
|
|
95
|
+
"Use update with a stable id to revise one existing task and remove to delete one task tree. Use append mainly when the user steers or adds genuinely new requirements while an active checklist is already in progress; do not use append as the default way to create a new plan.",
|
|
96
|
+
"write with todos is the complete current task plan and removes omitted tasks. Each item has content, status, and optional activeForm/subtasks.",
|
|
93
97
|
"Break a large task into `subtasks` (same shape, one level deep). A parent's status follows its subtasks: it is completed when all of them are. Progress counts leaf tasks (subtasks and tasks without subtasks).",
|
|
94
98
|
"Keep exactly one leaf task in_progress at a time. Mark a task in_progress before you start it and completed immediately after it is verified; do not batch completions.",
|
|
95
99
|
"Only mark a task completed when it is fully done. If tests fail, work is partial or you are blocked, keep it in_progress and add a task for what must be resolved. Remove tasks that are no longer relevant."
|
|
@@ -109,7 +113,7 @@ export function registerTaskTool(
|
|
|
109
113
|
if (!Array.isArray(params.todos)) throw new Error();
|
|
110
114
|
inputs = params.todos.map(toInput);
|
|
111
115
|
} catch { error = "todos must be an array of task objects with array subtasks."; }
|
|
112
|
-
if (!["write", "append", "
|
|
116
|
+
if (!["write", "append", "update"].includes(action)) error = "todos may only be used with write, append, or update; no tasks were changed.";
|
|
113
117
|
}
|
|
114
118
|
if (!error) switch (action) {
|
|
115
119
|
case "list":
|
|
@@ -125,15 +129,15 @@ export function registerTaskTool(
|
|
|
125
129
|
if (!error) changedId = current.all().find((item) => !before.has(item.id))?.id;
|
|
126
130
|
break;
|
|
127
131
|
}
|
|
128
|
-
case "
|
|
129
|
-
if (!params.id) { error = "
|
|
130
|
-
if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "
|
|
132
|
+
case "update": {
|
|
133
|
+
if (!params.id) { error = "update needs a valid existing task id."; break; }
|
|
134
|
+
if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "update accepts exactly one todos item OR title/status/activeForm fields."; break; }
|
|
131
135
|
const replacement = inputs?.[0] ?? {
|
|
132
136
|
...(params.title !== undefined ? { title: params.title } : {}),
|
|
133
137
|
...(params.status !== undefined ? { status: params.status } : {}),
|
|
134
138
|
...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {})
|
|
135
139
|
};
|
|
136
|
-
error = current.
|
|
140
|
+
error = current.update(params.id, replacement);
|
|
137
141
|
if (!error) changedId = params.id;
|
|
138
142
|
break;
|
|
139
143
|
}
|
|
@@ -154,7 +158,7 @@ export function registerTaskTool(
|
|
|
154
158
|
if (!item) error = `${action} needs a valid id from jar_todo list.`;
|
|
155
159
|
else if (action === "start" && progress.total && progress.done === progress.total) error = `every subtask of ${item.id} is completed; open it or add a subtask first.`;
|
|
156
160
|
else if (!current.setStatus(item.id, action === "start" ? "in_progress" : action === "done" ? "completed" : "pending")) error = "Could not save the task.";
|
|
157
|
-
else changedId = item.id;
|
|
161
|
+
else changedId = action === "start" ? (current.current()?.id ?? item.id) : item.id;
|
|
158
162
|
break;
|
|
159
163
|
}
|
|
160
164
|
case "edit":
|
|
@@ -177,6 +181,8 @@ export function registerTaskTool(
|
|
|
177
181
|
const group = root ? items.filter((item) => item.id === root || item.parentId === root) : [];
|
|
178
182
|
const mutationItems = action === "write" || action === "append" ? items : group;
|
|
179
183
|
const mutationPreview = mutationItems.slice(0, 8);
|
|
184
|
+
const terseMutation = action === "start" || action === "done";
|
|
185
|
+
const terseItem = terseMutation && changedId ? items.find((item) => item.id === changedId) : undefined;
|
|
180
186
|
// Keep task prose bounded while still returning every addressable id in canonical order.
|
|
181
187
|
const idIndex = mutationItems.length > 8
|
|
182
188
|
? "\nTask IDs (list order): " + items.map((item, index) => `${index + 1}=${item.id}`).join(", ") + "\nUse list for all task titles."
|
|
@@ -185,7 +191,11 @@ export function registerTaskTool(
|
|
|
185
191
|
? `Could not ${action} tasks: ${error} (${summary(items)}).`
|
|
186
192
|
: action === "list"
|
|
187
193
|
? `Tasks (${summary(items)}):\n${lines(items)}`
|
|
188
|
-
:
|
|
194
|
+
: action === "start" && terseItem
|
|
195
|
+
? `Started task:\n${line(items, terseItem)}`
|
|
196
|
+
: action === "done" && terseItem
|
|
197
|
+
? `Completed task:\n${line(items, terseItem)}`
|
|
198
|
+
: `Task list updated (${summary(items)}).${mutationPreview.length ? "\n" + mutationPreview.map((item) => line(items, item)).join("\n") : ""}${idIndex}\n${REMINDER}`;
|
|
189
199
|
// A subagent's parent mirrors the checklist from these details, so children send it whole.
|
|
190
200
|
const preview = process.env.PI_JAR_CHILD ? items : items.slice(0, 8);
|
|
191
201
|
const truncated = preview.length < items.length;
|
|
@@ -198,6 +208,7 @@ export function registerTaskTool(
|
|
|
198
208
|
details: {
|
|
199
209
|
action, items: preview, total: state.total, done: state.done, count: items.length,
|
|
200
210
|
ids: items.map(({ id, title, parentId }) => ({ id, title, ...(parentId ? { parentId } : {}) })),
|
|
211
|
+
...(terseItem ? { display: [{ ...terseItem }] } : {}),
|
|
201
212
|
...(state.current ? { current: state.current } : {}),
|
|
202
213
|
...(truncated ? { truncated: true } : {}),
|
|
203
214
|
...(progress.length ? { progress } : {}),
|
|
@@ -218,15 +229,19 @@ export function registerTaskTool(
|
|
|
218
229
|
if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
|
|
219
230
|
const details = result.details as TaskToolDetails | undefined;
|
|
220
231
|
const items = details?.items ?? [];
|
|
232
|
+
const displayItems = details?.display ?? items;
|
|
221
233
|
// Older results predate `count`, when `total` counted every item.
|
|
222
234
|
const count = details?.count ?? details?.total ?? items.length;
|
|
223
235
|
const totals = details?.total === undefined ? todoTotals(items) : { done: details.done, total: details.total };
|
|
224
236
|
if (!count) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
|
|
225
237
|
const known = new Map((details?.progress ?? []).map((value) => [value.id, value]));
|
|
226
|
-
const shown = expanded ? items : items.slice(0, 8);
|
|
227
|
-
let text = theme.fg("dim", `${totals.done}/${totals.total} done${details?.current ? " · now: " + details.current : ""}`);
|
|
228
|
-
for (const item of shown)
|
|
229
|
-
|
|
238
|
+
const shown = details?.display ? displayItems : expanded ? items : items.slice(0, 8);
|
|
239
|
+
let text = details?.display ? "" : theme.fg("dim", `${totals.done}/${totals.total} done${details?.current ? " · now: " + details.current : ""}`);
|
|
240
|
+
for (const item of shown) {
|
|
241
|
+
const row = todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value), known.get(item.id) ?? todoProgress(items, item.id));
|
|
242
|
+
text += (text ? "\n" : "") + row;
|
|
243
|
+
}
|
|
244
|
+
if (!details?.display && (details?.truncated || shown.length < count)) text += "\n" + theme.fg("dim", ` … +${Math.max(0, count - shown.length)} more · /jar tasks`);
|
|
230
245
|
return new Text(text, 0, 0);
|
|
231
246
|
}
|
|
232
247
|
});
|