pi-extended-teams 2.1.16

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.
Files changed (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,423 @@
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
+ import {
3
+ clearGlobalFavoriteModels,
4
+ FAVORITE_MODEL_SLOTS,
5
+ globalSettingsPath,
6
+ loadSettings,
7
+ normalizeFavoriteModelSlot,
8
+ replaceGlobalFavoriteModels,
9
+ setGlobalFavoriteModel,
10
+ THINKING_LEVEL_NAMES,
11
+ type CanonicalFavoriteModelSlot,
12
+ type FavoriteModelConfig,
13
+ type ThinkingLevelName,
14
+ } from "../../src/utils/settings";
15
+ import { normalizeQualifiedModel } from "../../src/utils/model-resolution";
16
+ import { clampThinkingLevel, getSupportedThinkingLevels } from "../../src/utils/thinking-levels";
17
+ import { getAvailableModels, type AvailableRegisteredModel } from "../internal/model-selection";
18
+
19
+ interface AvailableModelOption extends AvailableRegisteredModel {
20
+ qualified: string;
21
+ }
22
+
23
+ type FavoriteModelsDraft = Partial<Record<CanonicalFavoriteModelSlot, FavoriteModelConfig>>;
24
+ type PickerColumn = "slots" | "models" | "thinking";
25
+
26
+ const DEFAULT_THINKING_BY_SLOT: Record<CanonicalFavoriteModelSlot, ThinkingLevelName> = {
27
+ "read-collect": "high",
28
+ "read-review": "xhigh",
29
+ "read-analyze": "medium",
30
+ "read-critical": "xhigh",
31
+ "write-patch": "max",
32
+ "write-feature": "medium",
33
+ "write-system": "high",
34
+ "write-critical": "max",
35
+ };
36
+
37
+ const COLUMNS: PickerColumn[] = ["slots", "models", "thinking"];
38
+
39
+ function formatSlot(slot: CanonicalFavoriteModelSlot, config?: FavoriteModelConfig): string {
40
+ if (!config?.model || !config.thinking) return `- ${slot}: (empty)`;
41
+ return `- ${slot}: ${config.model} · ${config.thinking}`;
42
+ }
43
+
44
+ function usage(): string {
45
+ return [
46
+ "Usage:",
47
+ " /agents-favorite-models",
48
+ " /agents-favorite-models set <slot> <provider/model> <thinking>",
49
+ " /agents-favorite-models clear <slot>",
50
+ " /agents-favorite-models clear",
51
+ "",
52
+ `Slots: ${FAVORITE_MODEL_SLOTS.join(", ")}`,
53
+ `Thinking: ${THINKING_LEVEL_NAMES.join(", ")}`,
54
+ ].join("\n");
55
+ }
56
+
57
+ function formatCurrentSettings(homeDir?: string): string {
58
+ const settings = loadSettings({ homeDir });
59
+ return [
60
+ "Agent favorite models:",
61
+ ...FAVORITE_MODEL_SLOTS.map((slot) => formatSlot(slot, settings.favoriteModels[slot])),
62
+ "",
63
+ usage(),
64
+ "",
65
+ `Saved in: ${globalSettingsPath(homeDir)}`,
66
+ ].join("\n");
67
+ }
68
+
69
+ function normalizeSlot(raw: string | undefined): CanonicalFavoriteModelSlot {
70
+ const slot = normalizeFavoriteModelSlot(raw);
71
+ if (!slot) {
72
+ throw new Error(`Unknown slot "${raw ?? ""}". Use one of: ${FAVORITE_MODEL_SLOTS.join(", ")}.`);
73
+ }
74
+ return slot;
75
+ }
76
+
77
+ function normalizeThinking(raw: string | undefined): ThinkingLevelName {
78
+ if (!raw || !(THINKING_LEVEL_NAMES as readonly string[]).includes(raw)) {
79
+ throw new Error(`Invalid thinking level "${raw ?? ""}". Use one of: ${THINKING_LEVEL_NAMES.join(", ")}.`);
80
+ }
81
+ return raw as ThinkingLevelName;
82
+ }
83
+
84
+ function normalizeModel(raw: string | undefined): string {
85
+ const normalized = raw ? normalizeQualifiedModel(raw) : null;
86
+ if (!normalized) {
87
+ throw new Error("Model must be a fully qualified provider/model string.");
88
+ }
89
+ return normalized;
90
+ }
91
+
92
+ function formatQualifiedModel(model: { provider: string; model: string }): string {
93
+ return `${model.provider}/${model.model}`;
94
+ }
95
+
96
+ function sortAvailableModels(models: AvailableRegisteredModel[]): AvailableModelOption[] {
97
+ return models
98
+ .map((model) => ({ ...model, qualified: formatQualifiedModel(model) }))
99
+ .sort((a, b) => a.qualified.localeCompare(b.qualified));
100
+ }
101
+
102
+ function cloneFavoriteModels(input: FavoriteModelsDraft): FavoriteModelsDraft {
103
+ const draft: FavoriteModelsDraft = {};
104
+ for (const slot of FAVORITE_MODEL_SLOTS) {
105
+ const config = input[slot];
106
+ if (!config?.model || !config.thinking) continue;
107
+ draft[slot] = { model: config.model, thinking: config.thinking };
108
+ }
109
+ return draft;
110
+ }
111
+
112
+ function padToWidth(value: string, width: number): string {
113
+ const truncated = truncateToWidth(value, width, "…", true);
114
+ return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
115
+ }
116
+
117
+ function styledTitle(theme: any, label: string, active: boolean): string {
118
+ const title = active ? `▶ ${label}` : ` ${label}`;
119
+ return active ? theme.fg("accent", theme.bold(title)) : theme.fg("muted", title);
120
+ }
121
+
122
+ function defaultTheme(theme: any) {
123
+ return {
124
+ accent: (value: string) => theme.fg("accent", value),
125
+ dim: (value: string) => theme.fg("dim", value),
126
+ warning: (value: string) => theme.fg("warning", value),
127
+ success: (value: string) => theme.fg("success", value),
128
+ bold: (value: string) => theme.bold(value),
129
+ };
130
+ }
131
+
132
+ async function loadScopedModels(ctx: any): Promise<AvailableModelOption[]> {
133
+ return sortAvailableModels(await getAvailableModels(ctx));
134
+ }
135
+
136
+ async function showFavoriteModelsPicker(ctx: any): Promise<"saved" | "cancelled" | undefined> {
137
+ const availableModels = await loadScopedModels(ctx);
138
+ const availableSet = new Set(availableModels.map((model) => model.qualified));
139
+ const draft = cloneFavoriteModels(loadSettings().favoriteModels);
140
+
141
+ return ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: (value: "saved" | "cancelled") => void) => {
142
+ const colors = defaultTheme(theme);
143
+ let selectedSlotIndex = 0;
144
+ let activeColumnIndex = 0;
145
+ let modelFilter = "";
146
+ let modelScroll = 0;
147
+ let notice: string | undefined;
148
+
149
+ const activeColumn = () => COLUMNS[activeColumnIndex];
150
+ const selectedSlot = () => FAVORITE_MODEL_SLOTS[selectedSlotIndex];
151
+ const selectedConfig = () => draft[selectedSlot()];
152
+ const selectedModel = () => availableModels.find((model) => model.qualified === selectedConfig()?.model);
153
+ const selectedThinkingLevels = () => {
154
+ const model = selectedModel();
155
+ return model ? getSupportedThinkingLevels(model) : [];
156
+ };
157
+ const filteredModels = () => {
158
+ const filter = modelFilter.trim().toLowerCase();
159
+ if (!filter) return availableModels;
160
+ return availableModels.filter((model) => model.qualified.toLowerCase().includes(filter));
161
+ };
162
+
163
+ const ensureModelVisible = (modelRows: number) => {
164
+ const models = filteredModels();
165
+ const currentModel = selectedConfig()?.model;
166
+ const selectedModelIndex = Math.max(0, models.findIndex((model) => model.qualified === currentModel));
167
+ if (selectedModelIndex < modelScroll) modelScroll = selectedModelIndex;
168
+ if (selectedModelIndex >= modelScroll + modelRows) modelScroll = selectedModelIndex - modelRows + 1;
169
+ modelScroll = Math.max(0, Math.min(modelScroll, Math.max(0, models.length - modelRows)));
170
+ };
171
+
172
+ const setModelByDelta = (delta: number) => {
173
+ const models = filteredModels();
174
+ if (models.length === 0) return;
175
+ const slot = selectedSlot();
176
+ const currentModel = draft[slot]?.model;
177
+ const currentIndex = models.findIndex((model) => model.qualified === currentModel);
178
+ const nextIndex = Math.max(0, Math.min(models.length - 1, (currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : models.length) + delta));
179
+ const model = models[nextIndex];
180
+ const currentThinking = normalizeThinking(draft[slot]?.thinking ?? DEFAULT_THINKING_BY_SLOT[slot]);
181
+ draft[slot] = {
182
+ model: model.qualified,
183
+ thinking: clampThinkingLevel(model, currentThinking),
184
+ };
185
+ notice = undefined;
186
+ };
187
+
188
+ const setThinkingByDelta = (delta: number) => {
189
+ const slot = selectedSlot();
190
+ if (!draft[slot]?.model) {
191
+ notice = `Pick a scoped model for ${slot} before choosing thinking.`;
192
+ return;
193
+ }
194
+ const levels = selectedThinkingLevels();
195
+ if (levels.length === 0) {
196
+ notice = `The selected model for ${slot} is unavailable.`;
197
+ return;
198
+ }
199
+ const currentThinking = draft[slot]?.thinking ?? DEFAULT_THINKING_BY_SLOT[slot];
200
+ const currentIndex = Math.max(0, levels.findIndex((thinking) => thinking === currentThinking));
201
+ const nextIndex = Math.max(0, Math.min(levels.length - 1, currentIndex + delta));
202
+ draft[slot] = {
203
+ model: draft[slot]?.model ?? null,
204
+ thinking: levels[nextIndex],
205
+ };
206
+ notice = undefined;
207
+ };
208
+
209
+ const clearSelectedSlot = () => {
210
+ delete draft[selectedSlot()];
211
+ notice = undefined;
212
+ };
213
+
214
+ const clearAllSlots = () => {
215
+ for (const slot of FAVORITE_MODEL_SLOTS) delete draft[slot];
216
+ notice = undefined;
217
+ };
218
+
219
+ const saveAndClose = () => {
220
+ replaceGlobalFavoriteModels(draft);
221
+ done("saved");
222
+ };
223
+
224
+ const moveColumn = (delta: number) => {
225
+ activeColumnIndex = Math.max(0, Math.min(COLUMNS.length - 1, activeColumnIndex + delta));
226
+ };
227
+
228
+ const moveActiveSelection = (delta: number) => {
229
+ if (activeColumn() === "slots") {
230
+ selectedSlotIndex = Math.max(0, Math.min(FAVORITE_MODEL_SLOTS.length - 1, selectedSlotIndex + delta));
231
+ return;
232
+ }
233
+ if (activeColumn() === "models") {
234
+ setModelByDelta(delta);
235
+ return;
236
+ }
237
+ setThinkingByDelta(delta);
238
+ };
239
+
240
+ const modelRowsForTerminal = () => Math.max(5, Math.min(14, (tui.terminal?.rows ?? 24) - 13));
241
+
242
+ const buildSlotRows = (): string[] => {
243
+ return FAVORITE_MODEL_SLOTS.map((slot, index) => {
244
+ const config = draft[slot];
245
+ const selected = index === selectedSlotIndex;
246
+ const pointer = selected ? "›" : " ";
247
+ const model = config?.model ?? "empty";
248
+ const thinking = config?.thinking ?? "unset";
249
+ const unavailable = config?.model && !availableSet.has(config.model) ? " !unavailable" : "";
250
+ return `${pointer} ${slot} ${model} · ${thinking}${unavailable}`;
251
+ });
252
+ };
253
+
254
+ const buildModelRows = (rowCount: number): string[] => {
255
+ const models = filteredModels();
256
+ ensureModelVisible(rowCount);
257
+ if (models.length === 0) return [modelFilter ? "No scoped models match the filter." : "No scoped models available."];
258
+
259
+ const currentModel = selectedConfig()?.model;
260
+ const window = models.slice(modelScroll, modelScroll + rowCount);
261
+ const rows = window.map((model) => {
262
+ const selected = model.qualified === currentModel;
263
+ return `${selected ? "›" : " "} ${model.qualified}`;
264
+ });
265
+ if (modelScroll > 0) rows.unshift(`… ${modelScroll} more above`);
266
+ const below = models.length - modelScroll - window.length;
267
+ if (below > 0) rows.push(`… ${below} more below`);
268
+ return rows;
269
+ };
270
+
271
+ const buildThinkingRows = (): string[] => {
272
+ const config = selectedConfig();
273
+ if (!config?.model) return [" Pick a model first."];
274
+ const levels = selectedThinkingLevels();
275
+ if (levels.length === 0) return [" Model unavailable."];
276
+ return levels.map((thinking) => `${thinking === config.thinking ? "›" : " "} ${thinking}`);
277
+ };
278
+
279
+ const columnRows = (title: string, rows: string[], width: number, active: boolean): string[] => {
280
+ return [styledTitle(theme, title, active), ...rows].map((row) => padToWidth(row, width));
281
+ };
282
+
283
+ const render = (width: number): string[] => {
284
+ const innerWidth = Math.max(1, width);
285
+ const thinkingWidth = 16;
286
+ const slotWidth = Math.max(28, Math.floor(innerWidth * 0.38));
287
+ const modelWidth = Math.max(28, innerWidth - slotWidth - thinkingWidth - 6);
288
+ const modelRowCount = modelRowsForTerminal();
289
+ const slotRows = columnRows("slots", buildSlotRows(), slotWidth, activeColumn() === "slots");
290
+ const modelRows = columnRows(
291
+ `scoped models${modelFilter ? ` /${modelFilter}` : ""}`,
292
+ buildModelRows(modelRowCount),
293
+ modelWidth,
294
+ activeColumn() === "models",
295
+ );
296
+ const thinkingRows = columnRows("thinking", buildThinkingRows(), thinkingWidth, activeColumn() === "thinking");
297
+ const bodyRows = Math.max(slotRows.length, modelRows.length, thinkingRows.length);
298
+ const lines = [
299
+ colors.accent(colors.bold("Agent favorite models")),
300
+ colors.dim(`${availableModels.length} scoped model(s) available from this Pi session · saves to ${globalSettingsPath()}`),
301
+ colors.dim("←/→ or tab: move columns · ↑/↓: change selection · type while in scoped models to filter"),
302
+ colors.dim("enter: save · esc: cancel · delete: clear selected slot · ctrl+a: clear all"),
303
+ "",
304
+ ];
305
+
306
+ for (let index = 0; index < bodyRows; index += 1) {
307
+ lines.push(`${slotRows[index] ?? " ".repeat(slotWidth)} │ ${modelRows[index] ?? " ".repeat(modelWidth)} │ ${thinkingRows[index] ?? ""}`);
308
+ }
309
+
310
+ const config = selectedConfig();
311
+ lines.push("");
312
+ lines.push(colors.dim(`Selected ${selectedSlot()}: ${config?.model ?? "empty"} · ${config?.thinking ?? "unset"}`));
313
+ if (notice) {
314
+ lines.push(colors.warning(notice));
315
+ }
316
+ if (config?.model && !availableSet.has(config.model)) {
317
+ lines.push(colors.warning("This saved model is not in the scoped model list for the current session."));
318
+ }
319
+ if (activeColumn() === "models" && modelFilter) {
320
+ lines.push(colors.dim(`Filter: ${modelFilter} (backspace clears characters)`));
321
+ }
322
+
323
+ return lines.flatMap((line) => wrapTextWithAnsi(line, innerWidth));
324
+ };
325
+
326
+ const handleInput = (data: string) => {
327
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
328
+ done("cancelled");
329
+ return;
330
+ }
331
+ if (matchesKey(data, Key.enter) || data === "\r" || data === "\n") {
332
+ saveAndClose();
333
+ return;
334
+ }
335
+ if (activeColumn() !== "models" && (data === "q" || data === "Q")) {
336
+ done("cancelled");
337
+ return;
338
+ }
339
+ if (matchesKey(data, Key.right) || data === "l" || data === "L" || matchesKey(data, Key.tab)) {
340
+ moveColumn(1);
341
+ } else if (matchesKey(data, Key.left) || data === "h" || data === "H" || matchesKey(data, Key.shift("tab"))) {
342
+ moveColumn(-1);
343
+ } else if (matchesKey(data, Key.down) || (activeColumn() !== "models" && (data === "j" || data === "J"))) {
344
+ moveActiveSelection(1);
345
+ } else if (matchesKey(data, Key.up) || (activeColumn() !== "models" && (data === "k" || data === "K"))) {
346
+ moveActiveSelection(-1);
347
+ } else if (matchesKey(data, Key.delete)) {
348
+ clearSelectedSlot();
349
+ } else if (matchesKey(data, Key.ctrl("a"))) {
350
+ clearAllSlots();
351
+ } else if (activeColumn() === "models" && matchesKey(data, Key.backspace)) {
352
+ modelFilter = modelFilter.slice(0, -1);
353
+ modelScroll = 0;
354
+ } else if (activeColumn() === "models" && data.length === 1 && data.charCodeAt(0) >= 32) {
355
+ modelFilter += data;
356
+ modelScroll = 0;
357
+ }
358
+ tui.requestRender();
359
+ };
360
+
361
+ return { render, invalidate() {}, handleInput };
362
+ }, {
363
+ overlay: true,
364
+ overlayOptions: { width: "94%", maxHeight: "86%", anchor: "center" },
365
+ });
366
+ }
367
+
368
+ export function registerFavoriteModelsCommand(pi: any): void {
369
+ pi.registerCommand("agents-favorite-models", {
370
+ description: "View or configure favorite model/thinking slots for spawned agents.",
371
+ handler: async (args: string, ctx: any) => {
372
+ const parts = args.trim().split(/\s+/).filter(Boolean);
373
+ const action = parts[0];
374
+
375
+ try {
376
+ if (!action) {
377
+ if (ctx.mode === "tui" && typeof ctx.ui.custom === "function") {
378
+ const result = await showFavoriteModelsPicker(ctx);
379
+ if (result === "saved") ctx.ui.notify("Agent favorite models saved.", "info");
380
+ return;
381
+ }
382
+ ctx.ui.notify(formatCurrentSettings(), "info");
383
+ return;
384
+ }
385
+
386
+ if (action === "set") {
387
+ if (parts.length !== 4) throw new Error(`Expected: set <slot> <provider/model> <thinking>.\n${usage()}`);
388
+ const slot = normalizeSlot(parts[1]);
389
+ const model = normalizeModel(parts[2]);
390
+ const thinking = normalizeThinking(parts[3]);
391
+ const registeredModel = (await loadScopedModels(ctx)).find((candidate) => candidate.qualified === model);
392
+ if (!registeredModel) throw new Error(`Model "${model}" is not available in this Pi session.`);
393
+ const supportedThinking = getSupportedThinkingLevels(registeredModel);
394
+ if (!supportedThinking.includes(thinking)) {
395
+ throw new Error(
396
+ `Thinking level "${thinking}" is not available for ${model}. Use one of: ${supportedThinking.join(", ")}.`,
397
+ );
398
+ }
399
+ setGlobalFavoriteModel(slot, { model, thinking });
400
+ ctx.ui.notify(`Set ${slot} to ${model} · ${thinking}.`, "info");
401
+ return;
402
+ }
403
+
404
+ if (action === "clear") {
405
+ if (parts.length > 2) throw new Error(`Expected: clear [slot].\n${usage()}`);
406
+ if (parts[1]) {
407
+ const slot = normalizeSlot(parts[1]);
408
+ clearGlobalFavoriteModels({ slot });
409
+ ctx.ui.notify(`Cleared ${slot}.`, "info");
410
+ } else {
411
+ clearGlobalFavoriteModels();
412
+ ctx.ui.notify("Cleared all agent favorite model slots.", "info");
413
+ }
414
+ return;
415
+ }
416
+
417
+ throw new Error(`Unknown action "${action}".\n${usage()}`);
418
+ } catch (error) {
419
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
420
+ }
421
+ },
422
+ });
423
+ }
@@ -0,0 +1,77 @@
1
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+
3
+ const ANSI_RESET = "\x1b[0m";
4
+ const ANSI_PURPLE = "\x1b[38;5;141m";
5
+ const ANSI_PANEL_BG = "\x1b[48;5;235m";
6
+ const SINGLE_COLUMN_FRAME_TEXT = /^[\x20-\x7E\u00B7]*$/;
7
+
8
+ // Fill a line to `width` visible columns with the dark panel background. Every
9
+ // full reset emitted by pink/purple/dimAnsi/theme.fg is followed by a fresh
10
+ // background code so embedded foreground colors don't punch holes in the fill.
11
+ function panelBgFillMeasured(line: string, lineWidth: number, width: number, background: string): string {
12
+ const pad = Math.max(0, width - lineWidth);
13
+ const reasserted = line.includes(ANSI_RESET)
14
+ ? line.split(ANSI_RESET).join(ANSI_RESET + background)
15
+ : line;
16
+ return `${background}${reasserted}${" ".repeat(pad)}${ANSI_RESET}`;
17
+ }
18
+
19
+ export function panelBgFill(line: string, width: number, background = ANSI_PANEL_BG): string {
20
+ return panelBgFillMeasured(line, visibleWidth(line), width, background);
21
+ }
22
+
23
+ function renderFramePanelRow(line: string, innerWidth: number, span: number, background: string, sideBorder: string): string {
24
+ const singleColumn = SINGLE_COLUMN_FRAME_TEXT.test(line);
25
+ if (singleColumn && line.length <= innerWidth) {
26
+ return `${sideBorder}${background} ${line} ${" ".repeat(innerWidth - line.length)}${ANSI_RESET}${sideBorder}`;
27
+ }
28
+ const lineWidth = singleColumn ? line.length : visibleWidth(line);
29
+ const boundedLine = lineWidth > innerWidth ? truncateToWidth(line, innerWidth, "…", true) : line;
30
+ const boundedWidth = lineWidth > innerWidth ? (singleColumn ? boundedLine.length : visibleWidth(boundedLine)) : lineWidth;
31
+ return sideBorder + panelBgFillMeasured(` ${boundedLine} `, boundedWidth + 2, span, background) + sideBorder;
32
+ }
33
+
34
+ export function createFramePanelRowRenderer(innerWidth: number, background = ANSI_PANEL_BG): (line: string) => string {
35
+ const span = innerWidth + 2;
36
+ const sideBorder = `${background}${ANSI_PURPLE}│${ANSI_RESET}`;
37
+ return (line: string) => renderFramePanelRow(line, innerWidth, span, background, sideBorder);
38
+ }
39
+
40
+ // Wrap content lines in a rounded border with a dark interior. `innerWidth` is
41
+ // the column count between the one-space padding inside each side border.
42
+ export function framePanel(contentLines: string[], innerWidth: number, background = ANSI_PANEL_BG): string[] {
43
+ const span = innerWidth + 2;
44
+ const rule = "─".repeat(span);
45
+ const border = (text: string) => `${background}${ANSI_PURPLE}${text}${ANSI_RESET}`;
46
+ const sideBorder = border("│");
47
+ const out: string[] = [border(`╭${rule}╮`)];
48
+ for (const line of contentLines) {
49
+ const lineWidth = visibleWidth(line);
50
+ const boundedLine = lineWidth > innerWidth ? truncateToWidth(line, innerWidth, "…", true) : line;
51
+ const boundedWidth = lineWidth > innerWidth ? visibleWidth(boundedLine) : lineWidth;
52
+ out.push(sideBorder + panelBgFillMeasured(` ${boundedLine} `, boundedWidth + 2, span, background) + sideBorder);
53
+ }
54
+ out.push(border(`╰${rule}╯`));
55
+ return out;
56
+ }
57
+
58
+ // Self-sizing frame for compact panels so each reads as a distinct dark card.
59
+ export function frameWidget(contentLines: string[]): string[] {
60
+ const innerWidth = contentLines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
61
+ return framePanel(contentLines, innerWidth);
62
+ }
63
+
64
+ // Full-width frame for belowEditor status widgets. The returned lines consume
65
+ // the whole render width so the bottom bar has no left/right gutters.
66
+ export function frameWidgetFullWidth(contentLines: string[], width: number): string[] {
67
+ if (width <= 0) return [];
68
+ if (width < 4) {
69
+ return contentLines.map((line) => panelBgFill(truncateToWidth(line, width, "", true), width));
70
+ }
71
+ return framePanel(contentLines, width - 4);
72
+ }
73
+
74
+ export function logWindowStart(totalRows: number, viewportRows: number, offsetFromBottom: number): number {
75
+ const maxStart = Math.max(0, totalRows - viewportRows);
76
+ return Math.max(0, maxStart - Math.max(0, Math.min(offsetFromBottom, maxStart)));
77
+ }
@@ -0,0 +1,17 @@
1
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
2
+
3
+ export function isDownInput(data: string): boolean {
4
+ return matchesKey(data, Key.down) || data === "\x1b[B" || data === "j" || data === "J" || data === "\x0e";
5
+ }
6
+
7
+ export function isUpInput(data: string): boolean {
8
+ return matchesKey(data, Key.up) || data === "\x1b[A" || data === "k" || data === "K" || data === "\x10";
9
+ }
10
+
11
+ export function isLeftInput(data: string): boolean {
12
+ return matchesKey(data, Key.left) || data === "\x1b[D" || data === "h" || data === "H";
13
+ }
14
+
15
+ export function isRightInput(data: string): boolean {
16
+ return matchesKey(data, Key.right) || data === "\x1b[C" || data === "l" || data === "L";
17
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildReadAgentIdleNudgeMessage, describeReadAgentStatus, shouldNudgeReadAgentIdle, summarizeReadAgentStatuses } from "./read-agent-status.js";
3
+ import type { RunningReadAgent } from "../runtime/types.js";
4
+
5
+ function makeAgent(overrides: Partial<RunningReadAgent> = {}): RunningReadAgent {
6
+ const startedAt = Date.UTC(2026, 0, 1, 0, 0, 0);
7
+ return {
8
+ runId: "run-1",
9
+ name: "reader",
10
+ teamName: "team",
11
+ startedAt,
12
+ tokensUsed: 0,
13
+ status: "thinking",
14
+ recentEvents: [],
15
+ lastActivityAt: startedAt,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("read-agent status descriptions", () => {
21
+ it("describes normal thinking as waiting for model response", () => {
22
+ const agent = makeAgent({ status: "thinking" });
23
+
24
+ expect(describeReadAgentStatus(agent, agent.startedAt + 10_000)).toMatchObject({
25
+ label: "thinking",
26
+ detail: "waiting for model response",
27
+ idleLevel: "none",
28
+ });
29
+ });
30
+
31
+ it("describes normal working with the active tool name", () => {
32
+ const agent = makeAgent({ status: "working", activeToolName: "bash" });
33
+
34
+ expect(describeReadAgentStatus(agent, agent.startedAt + 10_000)).toMatchObject({
35
+ label: "working",
36
+ detail: "using bash",
37
+ idleLevel: "none",
38
+ });
39
+ });
40
+
41
+ it("trusts quiet agents for the first few minutes", () => {
42
+ const agent = makeAgent({ status: "thinking" });
43
+
44
+ expect(describeReadAgentStatus(agent, agent.startedAt + 61_000)).toMatchObject({
45
+ label: "thinking",
46
+ detail: "waiting for model response",
47
+ idleLevel: "none",
48
+ });
49
+ });
50
+
51
+ it("marks idle agents without instructing the lead to ping", () => {
52
+ const agent = makeAgent({ status: "thinking" });
53
+
54
+ expect(describeReadAgentStatus(agent, agent.startedAt + 301_000)).toMatchObject({
55
+ label: "idle",
56
+ detail: "no response/token change for 5m01s · visible in live agent view",
57
+ idleLevel: "soft",
58
+ idleMs: 301_000,
59
+ });
60
+ });
61
+
62
+ it("marks hanging agents without instructing the lead to ping", () => {
63
+ const agent = makeAgent({ status: "working", activeToolName: "read" });
64
+
65
+ expect(describeReadAgentStatus(agent, agent.startedAt + 901_000)).toMatchObject({
66
+ label: "hanging",
67
+ detail: "no response/token change for 15m01s · visible in live agent view",
68
+ idleLevel: "hard",
69
+ idleMs: 901_000,
70
+ });
71
+ });
72
+
73
+ it("does not wake the lead for idle/hanging read agents", () => {
74
+ expect(shouldNudgeReadAgentIdle(undefined, "none")).toBe(false);
75
+ expect(shouldNudgeReadAgentIdle(undefined, "soft")).toBe(false);
76
+ expect(shouldNudgeReadAgentIdle("soft", "soft")).toBe(false);
77
+ expect(shouldNudgeReadAgentIdle("soft", "hard")).toBe(false);
78
+ expect(shouldNudgeReadAgentIdle("hard", "hard")).toBe(false);
79
+ });
80
+
81
+ it("builds passive idle status messages", () => {
82
+ const agent = makeAgent({ name: "reviewer", teamName: "status-team" });
83
+
84
+ expect(buildReadAgentIdleNudgeMessage(agent, {
85
+ label: "idle",
86
+ detail: "",
87
+ idleLevel: "soft",
88
+ idleMs: 301_000,
89
+ })).toBe("Read agent reviewer on team status-team has gone quiet: no response or token change for 5m01s. Status is visible in the live agent view; do not ping/check repeatedly.");
90
+
91
+ expect(buildReadAgentIdleNudgeMessage(agent, {
92
+ label: "hanging",
93
+ detail: "",
94
+ idleLevel: "hard",
95
+ idleMs: 901_000,
96
+ })).toBe("Read agent reviewer on team status-team appears hung: no response or token change for 15m01s. Status is visible in the live agent view; do not ping/check repeatedly.");
97
+ });
98
+
99
+ it("summarizes many agents while limiting detailed status descriptions", () => {
100
+ const startedAt = Date.UTC(2026, 0, 1, 0, 0, 0);
101
+ const now = startedAt + 1_000_000;
102
+ const agents = [
103
+ makeAgent({ name: "thinking", status: "thinking", lastActivityAt: now - 10_000 }),
104
+ makeAgent({ name: "idle", status: "thinking", lastActivityAt: now - 301_000 }),
105
+ makeAgent({ name: "hanging", status: "working", lastActivityAt: now - 901_000 }),
106
+ makeAgent({ name: "working", status: "working", activeToolName: "bash", lastActivityAt: now - 5_000 }),
107
+ ];
108
+
109
+ const summary = summarizeReadAgentStatuses(agents, { now, maxDetailed: 2 });
110
+
111
+ expect(summary.total).toBe(4);
112
+ expect(summary.counts).toEqual({ thinking: 1, idle: 1, hanging: 1, working: 1 });
113
+ expect(summary.samples).toHaveLength(2);
114
+ expect(summary.samples.map(sample => sample.agent.name)).toEqual(["thinking", "idle"]);
115
+ expect(summary.samples.map(sample => sample.status.label)).toEqual(["thinking", "idle"]);
116
+ });
117
+ });